Astro has Full-Read SSRF in error rendering via Host: header injection
Description
Astro is a web framework. Prior to version 9.5.4, Server-Side Rendered pages that return an error with a prerendered custom error page (eg. 404.astro or 500.astro) are vulnerable to SSRF. If the Host: header is changed to an attacker's server, it will be fetched on /500.html and they can redirect this to any internal URL to read the response body through the first request. An attacker who can access the application without Host: header validation (eg. through finding the origin IP behind a proxy, or just by default) can fetch their own server to redirect to any internal IP. With this they can fetch cloud metadata IPs and interact with services in the internal network or localhost. For this to be vulnerable, a common feature needs to be used, with direct access to the server (no proxies). Version 9.5.4 fixes the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@astrojs/nodenpm | < 9.5.4 | 9.5.4 |
Affected products
1Patches
1e01e98b063e9Respect remote image allowlists (#15569)
12 files changed · +177 −25
.changeset/tame-lemons-probe.md+5 −0 added@@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Respect image allowlists when inferring remote image sizes and reject remote redirects.
packages/astro/src/assets/build/remote.ts+10 −2 modified@@ -9,7 +9,11 @@ export type RemoteCacheEntry = { export async function loadRemoteImage(src: string) { const req = new Request(src); - const res = await fetch(req); + const res = await fetch(req, { redirect: 'manual' }); + + if (res.status >= 300 && res.status < 400) { + throw new Error(`Failed to load remote image ${src}. The request was redirected.`); + } if (!res.ok) { throw new Error( @@ -47,7 +51,11 @@ export async function revalidateRemoteImage( ...(revalidationData.lastModified && { 'If-Modified-Since': revalidationData.lastModified }), }; const req = new Request(src, { headers, cache: 'no-cache' }); - const res = await fetch(req); + const res = await fetch(req, { redirect: 'manual' }); + + if (res.status >= 300 && res.status < 400) { + throw new Error(`Failed to revalidate cached remote image ${src}. The request was redirected.`); + } // Asset not modified: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304 if (!res.ok && res.status !== 304) {
packages/astro/src/assets/endpoint/generic.ts+5 −0 modified@@ -12,8 +12,13 @@ async function loadRemoteImage(src: URL, headers: Headers) { const res = await fetch(src, { // Forward all headers from the original request headers, + redirect: 'manual', }); + if (res.status >= 300 && res.status < 400) { + return undefined; + } + if (!res.ok) { return undefined; }
packages/astro/src/assets/endpoint/shared.ts+5 −1 modified@@ -8,7 +8,11 @@ import { etag } from '../utils/etag.js'; export async function loadRemoteImage(src: URL): Promise<Buffer | undefined> { try { - const res = await fetch(src); + const res = await fetch(src, { redirect: 'manual' }); + + if (res.status >= 300 && res.status < 400) { + return undefined; + } if (!res.ok) { return undefined;
packages/astro/src/assets/internal.ts+17 −10 modified@@ -1,4 +1,5 @@ import { isRemotePath } from '@astrojs/internal-helpers/path'; +import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import type { AstroConfig } from '../types/public/config.js'; import type { AstroAdapterClientConfig } from '../types/public/integrations.js'; @@ -78,17 +79,23 @@ export async function getImage( let originalHeight: number | undefined; // Infer size for remote images if inferSize is true - if ( - options.inferSize && - isRemoteImage(resolvedOptions.src) && - isRemotePath(resolvedOptions.src) - ) { - const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL - resolvedOptions.width ??= result.width; - resolvedOptions.height ??= result.height; - originalWidth = result.width; - originalHeight = result.height; + if (options.inferSize) { delete resolvedOptions.inferSize; // Delete so it doesn't end up in the attributes + + if (isRemoteImage(resolvedOptions.src) && isRemotePath(resolvedOptions.src)) { + if (!isRemoteAllowed(resolvedOptions.src, imageConfig)) { + throw new AstroError({ + ...AstroErrorData.RemoteImageNotAllowed, + message: AstroErrorData.RemoteImageNotAllowed.message(resolvedOptions.src), + }); + } + + const result = await inferRemoteSize(resolvedOptions.src, imageConfig); // Directly probe the image URL + resolvedOptions.width ??= result.width; + resolvedOptions.height ??= result.height; + originalWidth = result.width; + originalHeight = result.height; + } } const originalFilePath = isESMImportedImage(resolvedOptions.src)
packages/astro/src/assets/utils/remoteProbe.ts+49 −2 modified@@ -1,17 +1,64 @@ +import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; import { AstroError, AstroErrorData } from '../../core/errors/index.js'; +import type { AstroConfig } from '../../types/public/config.js'; import type { ImageMetadata } from '../types.js'; import { imageMetadata } from './metadata.js'; +type RemoteImageConfig = Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>; + /** * Infers the dimensions of a remote image by streaming its data and analyzing it progressively until sufficient metadata is available. * * @param {string} url - The URL of the remote image from which to infer size metadata. + * @param {RemoteImageConfig} [imageConfig] - Optional image config used to validate remote allowlists. * @return {Promise<Omit<ImageMetadata, 'src' | 'fsPath'>>} Returns a promise that resolves to an object containing the image dimensions metadata excluding `src` and `fsPath`. * @throws {AstroError} Thrown when the fetching fails or metadata cannot be extracted. */ -export async function inferRemoteSize(url: string): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> { +export async function inferRemoteSize( + url: string, + imageConfig?: RemoteImageConfig, +): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> { + if (!URL.canParse(url)) { + throw new AstroError({ + ...AstroErrorData.FailedToFetchRemoteImageDimensions, + message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url), + }); + } + + const allowlistConfig = imageConfig + ? { + domains: imageConfig.domains ?? [], + remotePatterns: imageConfig.remotePatterns ?? [], + } + : undefined; + + if (!allowlistConfig) { + const parsedUrl = new URL(url); + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new AstroError({ + ...AstroErrorData.FailedToFetchRemoteImageDimensions, + message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url), + }); + } + } + + if (allowlistConfig && !isRemoteAllowed(url, allowlistConfig)) { + throw new AstroError({ + ...AstroErrorData.RemoteImageNotAllowed, + message: AstroErrorData.RemoteImageNotAllowed.message(url), + }); + } + // Start fetching the image - const response = await fetch(url); + const response = await fetch(url, { redirect: 'manual' }); + + if (response.status >= 300 && response.status < 400) { + throw new AstroError({ + ...AstroErrorData.FailedToFetchRemoteImageDimensions, + message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url), + }); + } + if (!response.body || !response.ok) { throw new AstroError({ ...AstroErrorData.FailedToFetchRemoteImageDimensions,
packages/astro/src/assets/vite-plugin-assets.ts+8 −5 modified@@ -143,11 +143,11 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl if (id === resolvedVirtualModuleId) { return { code: ` - export { getConfiguredImageService, isLocalService } from "astro/assets"; - import { getImage as getImageInternal } from "astro/assets"; - export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro"; - export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro"; - export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js"; + export { getConfiguredImageService, isLocalService } from "astro/assets"; + import { getImage as getImageInternal } from "astro/assets"; + import { inferRemoteSize as inferRemoteSizeInternal } from "astro/assets/utils/inferRemoteSize.js"; + export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro"; + export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro"; export { default as Font } from "astro/components/Font.astro"; export * from "${RUNTIME_VIRTUAL_MODULE_ID}"; @@ -172,6 +172,9 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl enumerable: false, configurable: true, }); + export const inferRemoteSize = async (url) => { + return inferRemoteSizeInternal(url, imageConfig) + } // This is used by the @astrojs/node integration to locate images. // It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel) // new URL("dist/...") is interpreted by the bundler as a signal to include that directory
packages/astro/src/core/errors/errors-data.ts+14 −0 modified@@ -555,6 +555,20 @@ export const FailedToFetchRemoteImageDimensions = { message: (imageURL: string) => `Failed to get the dimensions for ${imageURL}.`, hint: 'Verify your remote image URL is accurate, and that you are not using `inferSize` with a file located in your `public/` folder.', } satisfies ErrorData; +/** + * @docs + * @message + * Remote image `IMAGE_URL` is not allowed by your image configuration. + * @description + * The remote image URL does not match your configured `image.domains` or `image.remotePatterns`. + */ +export const RemoteImageNotAllowed = { + name: 'RemoteImageNotAllowed', + title: 'Remote image is not allowed', + message: (imageURL: string) => + `Remote image ${imageURL} is not allowed by your image configuration.`, + hint: 'Update `image.domains` or `image.remotePatterns`, or remove `inferSize` for this image.', +} satisfies ErrorData; /** * @docs * @description
packages/astro/test/core-image-infersize.test.js+11 −0 modified@@ -76,5 +76,16 @@ describe('astro:image:infersize', () => { assert.equal($dimensions.text().trim(), '64x64'); }); }); + + it('rejects remote inferSize that is not allowlisted', async () => { + logs.length = 0; + const res = await fixture.fetch('/disallowed'); + await res.text(); + + const hasDisallowedLog = logs.some( + (log) => log.message.includes('Remote image') && log.message.includes('not allowed'), + ); + assert.equal(hasDisallowedLog, true); + }); }); });
packages/astro/test/core-image.test.js+42 −0 modified@@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; import { basename } from 'node:path'; import { Writable } from 'node:stream'; import { after, afterEach, before, describe, it } from 'node:test'; @@ -19,8 +20,30 @@ describe('astro:image', () => { let devServer; /** @type {Array<{ type: any, level: 'error', message: string; }>} */ let logs = []; + /** @type {import('node:http').Server | undefined} */ + let redirectServer; + /** @type {string | undefined} */ + let redirectUrl; before(async () => { + redirectServer = createServer((req, res) => { + if (req.url === '/redirect') { + res.statusCode = 302; + res.setHeader('Location', 'https://example.com/image.png'); + res.end(); + return; + } + + res.statusCode = 404; + res.end(); + }); + + await new Promise((resolve) => redirectServer.listen(0, '127.0.0.1', resolve)); + const address = redirectServer.address(); + if (address && typeof address === 'object') { + redirectUrl = `http://127.0.0.1:${address.port}/redirect`; + } + fixture = await loadFixture({ root: './fixtures/core-image/', image: { @@ -30,6 +53,15 @@ describe('astro:image', () => { { protocol: 'data', }, + ...(redirectUrl + ? [ + { + protocol: 'http', + hostname: '127.0.0.1', + port: new URL(redirectUrl).port, + }, + ] + : []), ], }, }); @@ -50,6 +82,9 @@ describe('astro:image', () => { after(async () => { await devServer.stop(); + if (redirectServer) { + await new Promise((resolve) => redirectServer.close(resolve)); + } }); describe('basics', () => { @@ -487,6 +522,13 @@ describe('astro:image', () => { assert.ok($img.attr('width')); assert.ok($img.attr('height')); }); + + it('rejects remote redirects', async () => { + assert.ok(redirectUrl, 'Expected redirect URL to be set'); + const src = `/_image?href=${encodeURIComponent(redirectUrl)}&w=1&h=1&f=png`; + const imageRequest = await fixture.fetch(src); + assert.ok(imageRequest.status >= 400); + }); }); it('error if no width and height', async () => {
packages/astro/test/fixtures/core-image-infersize/src/pages/disallowed.astro+6 −0 added@@ -0,0 +1,6 @@ +--- +import { inferRemoteSize } from 'astro:assets'; + +await inferRemoteSize('https://example.com/not-allowlisted.png'); +--- +<div>Should not render</div>
packages/astro/test/fixtures/core-image-infersize/src/pages/index.astro+5 −5 modified@@ -1,17 +1,17 @@ --- -// https://avatars.githubusercontent.com/u/622227?s=64 is a .jpeg +// https://avatars.githubusercontent.com/u/622227?s=64&v=4 is a .jpeg import { Image, Picture, getImage, inferRemoteSize } from 'astro:assets'; -const { width, height } = await inferRemoteSize('https://avatars.githubusercontent.com/u/622227?s=64'); +const { width, height } = await inferRemoteSize('https://avatars.githubusercontent.com/u/622227?s=64&v=4'); const remoteImg = await getImage({ - src: 'https://avatars.githubusercontent.com/u/622227?s=64', + src: 'https://avatars.githubusercontent.com/u/622227?s=64&v=4', inferSize: true, alt: '', }); --- -<Image src="https://avatars.githubusercontent.com/u/622227?s=64," inferSize={true} , alt="" /> -<Picture src="https://avatars.githubusercontent.com/u/622227?s=64," inferSize={true} , alt="" /> +<Image src="https://avatars.githubusercontent.com/u/622227?s=64&v=4" inferSize={true} alt="" /> +<Picture src="https://avatars.githubusercontent.com/u/622227?s=64&v=4" inferSize={true} alt="" /> <img src={remoteImg.src} {...remoteImg.attributes} id="getImage" /> <div id="direct">
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-qq67-mvv5-fw3gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-25545ghsaADVISORY
- docs.astro.build/en/basics/astro-pages/ghsaWEB
- github.com/withastro/astro/commit/e01e98b063e90d274c42130ec2a60cc0966622c9ghsax_refsource_MISCWEB
- github.com/withastro/astro/releases/tag/%40astrojs%2Fnode%409.5.4ghsax_refsource_MISCWEB
- github.com/withastro/astro/security/advisories/GHSA-qq67-mvv5-fw3gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.