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

Astro is vulnerable to SSRF due to missing allowlist enforcement in remote image inferSize

CVE-2026-27829

Description

Astro is a web framework. In versions 9.0.0 through 9.5.3, a bug in Astro's image pipeline allows bypassing image.domains / image.remotePatterns restrictions, enabling the server to fetch content from unauthorized remote hosts. Astro provides an inferSize option that fetches remote images at render time to determine their dimensions. Remote image fetches are intended to be restricted to domains the site developer has manually authorized (using the image.domains or image.remotePatterns options). However, when inferSize is used, no domain validation is performed — the image is fetched from any host regardless of the configured restrictions. An attacker who can influence the image URL (e.g., via CMS content or user-supplied data) can cause the server to fetch from arbitrary hosts. This allows bypassing image.domains / image.remotePatterns restrictions to make server-side requests to unauthorized hosts. This includes the risk of server-side request forgery (SSRF) against internal network services and cloud metadata endpoints. Version 9.5.4 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@astrojs/nodenpm
>= 9.0.0, < 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

4

News mentions

0

No linked articles in our index yet.