Skip to content

Commit 1b1c218

Browse files
feat: Support redirects on external image URLs (#16519)
Co-authored-by: Armand Philippot <git@armand.philippot.eu>
1 parent 8e0ff3d commit 1b1c218

13 files changed

Lines changed: 423 additions & 37 deletions

File tree

.changeset/khaki-keys-guess.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@astrojs/cloudflare': minor
3+
'astro': minor
4+
---
5+
6+
Adds support for redirecting URLs in remote image optimization.
7+
8+
Previously, when a remote image URL meant to be optimized by Astro led to a redirect, Astro would fail silently and ignore the redirect. Now, Astro tracks up to 10 redirects for these images. If any of the redirects are not covered by a pattern in `image.remotePatterns` or a domain in `image.domains`, Astro will fail with a helpful error message.
9+
10+
In the following example, the first image would be loaded successfully, while the second would lead to Astro throwing an error:
11+
12+
```mjs
13+
export default defineConfig({
14+
image: {
15+
domains: ["example.com", "cdn.example.com"]
16+
}
17+
});
18+
```
19+
20+
```tsx
21+
{/* Redirects to https://cdn.example.com/assets/image.png: */}
22+
<Image src="https://example.com/assets/image.png" width="1920" height="1080" alt="An example image." />
23+
24+
{/* Redirects to https://malicious.com/image.png: */}
25+
<Image src="https://example.com/bad-image.png" width="1920" height="1080" alt="An example image." />
26+
```
27+
28+
In cases where all redirects to HTTPS hosts should be trusted, the following configuration for `image.remotePatterns` can be used:
29+
30+
```mjs
31+
export default defineConfig({
32+
image: {
33+
remotePatterns: [{
34+
protocol: 'https'
35+
}]
36+
}
37+
});
38+
```

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ export function getStaticImageList(): AssetsGlobalStaticImagesList {
355355

356356
async function loadImage(path: string, env: AssetEnv): Promise<ImageData> {
357357
if (isRemotePath(path)) {
358-
return await loadRemoteImage(path);
358+
return await loadRemoteImage(path, undefined, env.imageConfig);
359359
}
360360

361361
return {

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

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import CachePolicy from 'http-cache-semantics';
2+
import { fetchWithRedirects, type RemoteImageConfig } from '../utils/redirectValidation.js';
23

34
export type RemoteCacheEntry = {
45
data?: string;
@@ -7,13 +8,16 @@ export type RemoteCacheEntry = {
78
lastModified?: string;
89
};
910

10-
export async function loadRemoteImage(src: string, fetchFn: typeof fetch = globalThis.fetch) {
11-
const req = new Request(src);
12-
const res = await fetchFn(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-
}
11+
export async function loadRemoteImage(
12+
src: string,
13+
fetchFn: typeof fetch = globalThis.fetch,
14+
imageConfig: RemoteImageConfig = { remotePatterns: [], domains: [] },
15+
) {
16+
const res = await fetchWithRedirects({
17+
url: src,
18+
fetchFn,
19+
imageConfig,
20+
});
1721

1822
if (!res.ok) {
1923
throw new Error(
@@ -22,6 +26,7 @@ export async function loadRemoteImage(src: string, fetchFn: typeof fetch = globa
2226
}
2327

2428
// calculate an expiration date based on the response's TTL
29+
const req = new Request(src);
2530
const policy = new CachePolicy(webToCachePolicyRequest(req), webToCachePolicyResponse(res));
2631
const expires = policy.storable() ? policy.timeToLive() : 0;
2732

@@ -46,13 +51,19 @@ export async function revalidateRemoteImage(
4651
src: string,
4752
revalidationData: { etag?: string; lastModified?: string },
4853
fetchFn: typeof fetch = globalThis.fetch,
54+
imageConfig: RemoteImageConfig = { remotePatterns: [], domains: [] },
4955
) {
5056
const headers = {
5157
...(revalidationData.etag && { 'If-None-Match': revalidationData.etag }),
5258
...(revalidationData.lastModified && { 'If-Modified-Since': revalidationData.lastModified }),
5359
};
5460
const req = new Request(src, { headers, cache: 'no-cache' });
55-
const res = await fetchFn(req, { redirect: 'manual' });
61+
const res = await fetchWithRedirects({
62+
url: src,
63+
headers: new Headers(headers),
64+
imageConfig,
65+
fetchFn,
66+
});
5667

5768
// Allow 304 Not Modified: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304
5869
if (!res.ok && res.status !== 304) {
@@ -70,7 +81,7 @@ export async function revalidateRemoteImage(
7081

7182
if (res.ok && !data.length) {
7283
// Server did not include body but indicated cache was stale
73-
return await loadRemoteImage(src, fetchFn);
84+
return await loadRemoteImage(src, fetchFn, imageConfig);
7485
}
7586

7687
// calculate an expiration date based on the response's TTL

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,14 @@ import * as mime from 'mrmime';
66
import type { APIRoute } from '../../types/public/common.js';
77
import { getConfiguredImageService } from '../internal.js';
88
import { etag } from '../utils/etag.js';
9+
import { fetchWithRedirects } from '../utils/redirectValidation.js';
910

1011
async function loadRemoteImage(src: URL, headers: Headers) {
1112
try {
12-
const res = await fetch(src, {
13-
// Forward all headers from the original request
14-
headers,
15-
redirect: 'manual',
16-
});
13+
const res = await fetchWithRedirects({ url: src, headers, imageConfig });
1714

18-
if (res.status >= 300 && res.status < 400) {
15+
// Validate that the final URL (after redirects) is allowed
16+
if (!isRemoteAllowed(res.url, imageConfig)) {
1917
return undefined;
2018
}
2119

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ import * as mime from 'mrmime';
66
import { getConfiguredImageService } from '../internal.js';
77
import { etag } from '../utils/etag.js';
88
import { inferSourceFormat } from '../utils/inferSourceFormat.js';
9+
import { fetchWithRedirects } from '../utils/redirectValidation.js';
10+
11+
const isLocal = (url: string) => {
12+
const hostname = new URL(url).hostname;
13+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
14+
};
915

1016
export async function loadRemoteImage(src: URL): Promise<Buffer | undefined> {
1117
try {
12-
const res = await fetch(src, { redirect: 'manual' });
18+
const res = await fetchWithRedirects({ url: src, imageConfig });
1319

14-
if (res.status >= 300 && res.status < 400) {
20+
// Local URLs are allowed by default
21+
if (!isRemoteAllowed(res.url, imageConfig) && !isLocal(res.url)) {
1522
return undefined;
1623
}
1724

packages/astro/src/assets/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { getConfiguredImageService, getImage, verifyOptions } from './internal.j
22
export { baseService, isLocalService } from './services/service.js';
33
export { hashTransform, propsToFilename } from './utils/hash.js';
44
export type { LocalImageProps, RemoteImageProps } from './types.js';
5+
export { fetchWithRedirects } from './utils/redirectValidation.js';

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export { isESMImportedImage, isRemoteImage, resolveSrc } from './imageKind.js';
1515
export { imageMetadata } from './metadata.js';
1616
export { getOrigQueryParams } from './queryParams.js';
1717
export { inferRemoteSize } from './remoteProbe.js';
18+
export { fetchWithRedirects } from './redirectValidation.js';
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Utilities for handling HTTP redirects with validation
3+
*/
4+
5+
import type { AstroConfig } from '../../types/public/config.js';
6+
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
7+
8+
export type RemoteImageConfig = Pick<AstroConfig['image'], 'remotePatterns' | 'domains'>;
9+
10+
export type FetchRedirectOptions = {
11+
/**
12+
* URL to fetch (either string or URL object)
13+
*/
14+
url: string | URL;
15+
16+
/**
17+
* Headers to include in the request (optional)
18+
*/
19+
headers?: Headers;
20+
21+
/**
22+
* Image config for validating redirect destinations (optional)
23+
*/
24+
imageConfig: RemoteImageConfig;
25+
26+
/**
27+
* Fetch function to use (default: globalThis.fetch)
28+
*/
29+
fetchFn?: typeof fetch;
30+
31+
/**
32+
* Maximum number of redirects to follow (default: 10)
33+
*/
34+
redirectLimit?: number;
35+
36+
/**
37+
* Error handler for redirect depth exceeded (default: generic Error)
38+
*/
39+
onMaxRedirectsExceeded?: (url: string) => Error;
40+
41+
/**
42+
* Error handler for missing Location header (default: generic Error)
43+
*/
44+
onMissingLocationHeader?: (status: number, url: string) => Error;
45+
46+
/**
47+
* Error handler for disallowed redirect (default: generic Error)
48+
*/
49+
onDisallowedRedirect?: (currentUrl: string, targetUrl: string) => Error;
50+
};
51+
52+
/**
53+
* Recursively follows HTTP redirects with validation according to the image configuration.
54+
*
55+
* If any of the domains in the redirect chain are not allowed by either `image.remotePatterns`
56+
* or `image.domains`, this function will throw an error for a disallowed redirect.
57+
*
58+
* @param options The options for this fetch call.
59+
*/
60+
export async function fetchWithRedirects(options: FetchRedirectOptions): Promise<Response> {
61+
const {
62+
url,
63+
headers,
64+
imageConfig,
65+
fetchFn = globalThis.fetch,
66+
redirectLimit = 10,
67+
onMaxRedirectsExceeded = (_u) => new Error('Maximum redirect depth exceeded'),
68+
onMissingLocationHeader = (_s, _u) =>
69+
new Error(`Redirect response ${_s} missing Location header`),
70+
onDisallowedRedirect = (_current, _target) =>
71+
new Error(
72+
`The image at ${_current} redirected to ${_target}, which is not an allowed remote location.`,
73+
),
74+
} = options;
75+
76+
if (redirectLimit <= 0) {
77+
throw onMaxRedirectsExceeded(typeof url === 'string' ? url : url.toString());
78+
}
79+
80+
const urlString = typeof url === 'string' ? url : url.toString();
81+
const req = new Request(url, { headers });
82+
const res = await fetchFn(req, { redirect: 'manual' });
83+
84+
// Handle redirects (301, 302, 303, 307, 308 are actual redirects, not 304 Not Modified)
85+
if ([301, 302, 303, 307, 308].includes(res.status)) {
86+
const location = res.headers.get('Location');
87+
if (!location) {
88+
throw onMissingLocationHeader(res.status, urlString);
89+
}
90+
91+
// Resolve the redirect URL relative to the current URL
92+
const redirectUrl = new URL(location, urlString).toString();
93+
94+
// Validate that the redirect target matches allowed patterns
95+
if (
96+
!isRemoteAllowed(redirectUrl, {
97+
domains: imageConfig.domains ?? [],
98+
remotePatterns: imageConfig.remotePatterns ?? [],
99+
})
100+
) {
101+
throw onDisallowedRedirect(urlString, redirectUrl);
102+
}
103+
104+
// Recursively follow the redirect
105+
return fetchWithRedirects({
106+
url: redirectUrl,
107+
headers,
108+
imageConfig,
109+
fetchFn,
110+
redirectLimit: redirectLimit - 1,
111+
onMaxRedirectsExceeded,
112+
onMissingLocationHeader,
113+
onDisallowedRedirect,
114+
});
115+
}
116+
117+
return res;
118+
}

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js';
33
import type { AstroConfig } from '../../types/public/config.js';
44
import type { ImageMetadata } from '../types.js';
55
import { imageMetadata } from './metadata.js';
6+
import { fetchWithRedirects } from './redirectValidation.js';
67

78
type RemoteImageConfig = Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>;
89

@@ -49,16 +50,41 @@ export async function inferRemoteSize(
4950
});
5051
}
5152

52-
// Start fetching the image
53-
const response = await fetch(url, { redirect: 'manual' });
54-
55-
if (response.status >= 300 && response.status < 400) {
53+
// Start fetching the image with redirect validation
54+
let response: Response;
55+
try {
56+
response = await fetchWithRedirects({
57+
url,
58+
onMaxRedirectsExceeded: (u) =>
59+
new AstroError({
60+
...AstroErrorData.FailedToFetchRemoteImageDimensions,
61+
message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(u),
62+
}),
63+
onMissingLocationHeader: (_status, u) =>
64+
new AstroError({
65+
...AstroErrorData.FailedToFetchRemoteImageDimensions,
66+
message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(u),
67+
}),
68+
imageConfig: imageConfig ?? {
69+
remotePatterns: [],
70+
domains: [],
71+
},
72+
});
73+
} catch (_err) {
5674
throw new AstroError({
5775
...AstroErrorData.FailedToFetchRemoteImageDimensions,
5876
message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url),
5977
});
6078
}
6179

80+
// Validate that the final URL (after redirects) is allowed
81+
if (allowlistConfig && !isRemoteAllowed(response.url, allowlistConfig)) {
82+
throw new AstroError({
83+
...AstroErrorData.RemoteImageNotAllowed,
84+
message: AstroErrorData.RemoteImageNotAllowed.message(url),
85+
});
86+
}
87+
6288
if (!response.body || !response.ok) {
6389
throw new AstroError({
6490
...AstroErrorData.FailedToFetchRemoteImageDimensions,

packages/astro/src/types/public/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,6 +1865,8 @@ export interface AstroUserConfig<
18651865
* `pathname` patterns:
18661866
* - End with `/**` to allow all sub-routes (like `startsWith`).
18671867
* - End with `/*` to allow only one level of sub-route.
1868+
*
1869+
* HTTP redirects are also followed when an image URL matches a remote pattern. The final destination URL must be among the allowed remote patterns to be loaded.
18681870
18691871
*/
18701872
remotePatterns?: Partial<RemotePattern>[];

0 commit comments

Comments
 (0)