Skip to content

Commit e01e98b

Browse files
authored
Respect remote image allowlists (#15569)
* Respect remote image allowlists * Fix inferRemoteSize virtual module import
1 parent f2955fb commit e01e98b

File tree

12 files changed

+177
-25
lines changed

12 files changed

+177
-25
lines changed

.changeset/tame-lemons-probe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"astro": patch
3+
---
4+
5+
Respect image allowlists when inferring remote image sizes and reject remote redirects.

packages/astro/src/assets/build/remote.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ export type RemoteCacheEntry = {
99

1010
export async function loadRemoteImage(src: string) {
1111
const req = new Request(src);
12-
const res = await fetch(req);
12+
const res = await fetch(req, { redirect: 'manual' });
13+
14+
if (res.status >= 300 && res.status < 400) {
15+
throw new Error(`Failed to load remote image ${src}. The request was redirected.`);
16+
}
1317

1418
if (!res.ok) {
1519
throw new Error(
@@ -47,7 +51,11 @@ export async function revalidateRemoteImage(
4751
...(revalidationData.lastModified && { 'If-Modified-Since': revalidationData.lastModified }),
4852
};
4953
const req = new Request(src, { headers, cache: 'no-cache' });
50-
const res = await fetch(req);
54+
const res = await fetch(req, { redirect: 'manual' });
55+
56+
if (res.status >= 300 && res.status < 400) {
57+
throw new Error(`Failed to revalidate cached remote image ${src}. The request was redirected.`);
58+
}
5159

5260
// Asset not modified: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304
5361
if (!res.ok && res.status !== 304) {

packages/astro/src/assets/endpoint/generic.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ async function loadRemoteImage(src: URL, headers: Headers) {
1212
const res = await fetch(src, {
1313
// Forward all headers from the original request
1414
headers,
15+
redirect: 'manual',
1516
});
1617

18+
if (res.status >= 300 && res.status < 400) {
19+
return undefined;
20+
}
21+
1722
if (!res.ok) {
1823
return undefined;
1924
}

packages/astro/src/assets/endpoint/shared.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { etag } from '../utils/etag.js';
88

99
export async function loadRemoteImage(src: URL): Promise<Buffer | undefined> {
1010
try {
11-
const res = await fetch(src);
11+
const res = await fetch(src, { redirect: 'manual' });
12+
13+
if (res.status >= 300 && res.status < 400) {
14+
return undefined;
15+
}
1216

1317
if (!res.ok) {
1418
return undefined;

packages/astro/src/assets/internal.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isRemotePath } from '@astrojs/internal-helpers/path';
2+
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
23
import { AstroError, AstroErrorData } from '../core/errors/index.js';
34
import type { AstroConfig } from '../types/public/config.js';
45
import type { AstroAdapterClientConfig } from '../types/public/integrations.js';
@@ -78,17 +79,23 @@ export async function getImage(
7879
let originalHeight: number | undefined;
7980

8081
// Infer size for remote images if inferSize is true
81-
if (
82-
options.inferSize &&
83-
isRemoteImage(resolvedOptions.src) &&
84-
isRemotePath(resolvedOptions.src)
85-
) {
86-
const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL
87-
resolvedOptions.width ??= result.width;
88-
resolvedOptions.height ??= result.height;
89-
originalWidth = result.width;
90-
originalHeight = result.height;
82+
if (options.inferSize) {
9183
delete resolvedOptions.inferSize; // Delete so it doesn't end up in the attributes
84+
85+
if (isRemoteImage(resolvedOptions.src) && isRemotePath(resolvedOptions.src)) {
86+
if (!isRemoteAllowed(resolvedOptions.src, imageConfig)) {
87+
throw new AstroError({
88+
...AstroErrorData.RemoteImageNotAllowed,
89+
message: AstroErrorData.RemoteImageNotAllowed.message(resolvedOptions.src),
90+
});
91+
}
92+
93+
const result = await inferRemoteSize(resolvedOptions.src, imageConfig); // Directly probe the image URL
94+
resolvedOptions.width ??= result.width;
95+
resolvedOptions.height ??= result.height;
96+
originalWidth = result.width;
97+
originalHeight = result.height;
98+
}
9299
}
93100

94101
const originalFilePath = isESMImportedImage(resolvedOptions.src)

packages/astro/src/assets/utils/remoteProbe.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,64 @@
1+
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
12
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
3+
import type { AstroConfig } from '../../types/public/config.js';
24
import type { ImageMetadata } from '../types.js';
35
import { imageMetadata } from './metadata.js';
46

7+
type RemoteImageConfig = Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>;
8+
59
/**
610
* Infers the dimensions of a remote image by streaming its data and analyzing it progressively until sufficient metadata is available.
711
*
812
* @param {string} url - The URL of the remote image from which to infer size metadata.
13+
* @param {RemoteImageConfig} [imageConfig] - Optional image config used to validate remote allowlists.
914
* @return {Promise<Omit<ImageMetadata, 'src' | 'fsPath'>>} Returns a promise that resolves to an object containing the image dimensions metadata excluding `src` and `fsPath`.
1015
* @throws {AstroError} Thrown when the fetching fails or metadata cannot be extracted.
1116
*/
12-
export async function inferRemoteSize(url: string): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> {
17+
export async function inferRemoteSize(
18+
url: string,
19+
imageConfig?: RemoteImageConfig,
20+
): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> {
21+
if (!URL.canParse(url)) {
22+
throw new AstroError({
23+
...AstroErrorData.FailedToFetchRemoteImageDimensions,
24+
message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url),
25+
});
26+
}
27+
28+
const allowlistConfig = imageConfig
29+
? {
30+
domains: imageConfig.domains ?? [],
31+
remotePatterns: imageConfig.remotePatterns ?? [],
32+
}
33+
: undefined;
34+
35+
if (!allowlistConfig) {
36+
const parsedUrl = new URL(url);
37+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
38+
throw new AstroError({
39+
...AstroErrorData.FailedToFetchRemoteImageDimensions,
40+
message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url),
41+
});
42+
}
43+
}
44+
45+
if (allowlistConfig && !isRemoteAllowed(url, allowlistConfig)) {
46+
throw new AstroError({
47+
...AstroErrorData.RemoteImageNotAllowed,
48+
message: AstroErrorData.RemoteImageNotAllowed.message(url),
49+
});
50+
}
51+
1352
// Start fetching the image
14-
const response = await fetch(url);
53+
const response = await fetch(url, { redirect: 'manual' });
54+
55+
if (response.status >= 300 && response.status < 400) {
56+
throw new AstroError({
57+
...AstroErrorData.FailedToFetchRemoteImageDimensions,
58+
message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url),
59+
});
60+
}
61+
1562
if (!response.body || !response.ok) {
1663
throw new AstroError({
1764
...AstroErrorData.FailedToFetchRemoteImageDimensions,

packages/astro/src/assets/vite-plugin-assets.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,11 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
143143
if (id === resolvedVirtualModuleId) {
144144
return {
145145
code: `
146-
export { getConfiguredImageService, isLocalService } from "astro/assets";
147-
import { getImage as getImageInternal } from "astro/assets";
148-
export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro";
149-
export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro";
150-
export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js";
146+
export { getConfiguredImageService, isLocalService } from "astro/assets";
147+
import { getImage as getImageInternal } from "astro/assets";
148+
import { inferRemoteSize as inferRemoteSizeInternal } from "astro/assets/utils/inferRemoteSize.js";
149+
export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro";
150+
export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro";
151151
152152
export { default as Font } from "astro/components/Font.astro";
153153
export * from "${RUNTIME_VIRTUAL_MODULE_ID}";
@@ -172,6 +172,9 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
172172
enumerable: false,
173173
configurable: true,
174174
});
175+
export const inferRemoteSize = async (url) => {
176+
return inferRemoteSizeInternal(url, imageConfig)
177+
}
175178
// This is used by the @astrojs/node integration to locate images.
176179
// It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel)
177180
// new URL("dist/...") is interpreted by the bundler as a signal to include that directory

packages/astro/src/core/errors/errors-data.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,20 @@ export const FailedToFetchRemoteImageDimensions = {
555555
message: (imageURL: string) => `Failed to get the dimensions for ${imageURL}.`,
556556
hint: 'Verify your remote image URL is accurate, and that you are not using `inferSize` with a file located in your `public/` folder.',
557557
} satisfies ErrorData;
558+
/**
559+
* @docs
560+
* @message
561+
* Remote image `IMAGE_URL` is not allowed by your image configuration.
562+
* @description
563+
* The remote image URL does not match your configured `image.domains` or `image.remotePatterns`.
564+
*/
565+
export const RemoteImageNotAllowed = {
566+
name: 'RemoteImageNotAllowed',
567+
title: 'Remote image is not allowed',
568+
message: (imageURL: string) =>
569+
`Remote image ${imageURL} is not allowed by your image configuration.`,
570+
hint: 'Update `image.domains` or `image.remotePatterns`, or remove `inferSize` for this image.',
571+
} satisfies ErrorData;
558572
/**
559573
* @docs
560574
* @description

packages/astro/test/core-image-infersize.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,16 @@ describe('astro:image:infersize', () => {
7676
assert.equal($dimensions.text().trim(), '64x64');
7777
});
7878
});
79+
80+
it('rejects remote inferSize that is not allowlisted', async () => {
81+
logs.length = 0;
82+
const res = await fixture.fetch('/disallowed');
83+
await res.text();
84+
85+
const hasDisallowedLog = logs.some(
86+
(log) => log.message.includes('Remote image') && log.message.includes('not allowed'),
87+
);
88+
assert.equal(hasDisallowedLog, true);
89+
});
7990
});
8091
});

packages/astro/test/core-image.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from 'node:assert/strict';
2+
import { createServer } from 'node:http';
23
import { basename } from 'node:path';
34
import { Writable } from 'node:stream';
45
import { after, afterEach, before, describe, it } from 'node:test';
@@ -19,8 +20,30 @@ describe('astro:image', () => {
1920
let devServer;
2021
/** @type {Array<{ type: any, level: 'error', message: string; }>} */
2122
let logs = [];
23+
/** @type {import('node:http').Server | undefined} */
24+
let redirectServer;
25+
/** @type {string | undefined} */
26+
let redirectUrl;
2227

2328
before(async () => {
29+
redirectServer = createServer((req, res) => {
30+
if (req.url === '/redirect') {
31+
res.statusCode = 302;
32+
res.setHeader('Location', 'https://example.com/image.png');
33+
res.end();
34+
return;
35+
}
36+
37+
res.statusCode = 404;
38+
res.end();
39+
});
40+
41+
await new Promise((resolve) => redirectServer.listen(0, '127.0.0.1', resolve));
42+
const address = redirectServer.address();
43+
if (address && typeof address === 'object') {
44+
redirectUrl = `http://127.0.0.1:${address.port}/redirect`;
45+
}
46+
2447
fixture = await loadFixture({
2548
root: './fixtures/core-image/',
2649
image: {
@@ -30,6 +53,15 @@ describe('astro:image', () => {
3053
{
3154
protocol: 'data',
3255
},
56+
...(redirectUrl
57+
? [
58+
{
59+
protocol: 'http',
60+
hostname: '127.0.0.1',
61+
port: new URL(redirectUrl).port,
62+
},
63+
]
64+
: []),
3365
],
3466
},
3567
});
@@ -50,6 +82,9 @@ describe('astro:image', () => {
5082

5183
after(async () => {
5284
await devServer.stop();
85+
if (redirectServer) {
86+
await new Promise((resolve) => redirectServer.close(resolve));
87+
}
5388
});
5489

5590
describe('basics', () => {
@@ -487,6 +522,13 @@ describe('astro:image', () => {
487522
assert.ok($img.attr('width'));
488523
assert.ok($img.attr('height'));
489524
});
525+
526+
it('rejects remote redirects', async () => {
527+
assert.ok(redirectUrl, 'Expected redirect URL to be set');
528+
const src = `/_image?href=${encodeURIComponent(redirectUrl)}&w=1&h=1&f=png`;
529+
const imageRequest = await fixture.fetch(src);
530+
assert.ok(imageRequest.status >= 400);
531+
});
490532
});
491533

492534
it('error if no width and height', async () => {

0 commit comments

Comments
 (0)