VYPR
Moderate severityNVD Advisory· Published Feb 24, 2026· Updated Feb 26, 2026

Astro has Full-Read SSRF in error rendering via Host: header injection

CVE-2026-25545

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.

PackageAffected versionsPatched versions
@astrojs/nodenpm
< 9.5.49.5.4

Affected products

1

Patches

1
e01e98b063e9

Respect remote image allowlists (#15569)

https://github.com/withastro/astroMatthew PhillipsFeb 18, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.