VYPR
High severityNVD Advisory· Published Oct 28, 2025· Updated Oct 29, 2025

astro allows bypass of image proxy domain validation leading to SSRF and potential XSS

CVE-2025-59837

Description

Astro is a web framework that includes an image proxy. In versions 5.13.4 and later before 5.13.10, the image proxy domain validation can be bypassed by using backslashes in the href parameter, allowing server-side requests to arbitrary URLs. This can lead to server-side request forgery (SSRF) and potentially cross-site scripting (XSS). This vulnerability exists due to an incomplete fix for CVE-2025-58179. Fixed in 5.13.10.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
astronpm
>= 5.13.4, < 5.13.105.13.10

Affected products

1

Patches

2
1e2499e8ea83

fix(internal-helpers): improve isRemotePath to handle backslash URLs (#14408)

https://github.com/withastro/astroMatthew PhillipsSep 22, 2025via ghsa
4 files changed · +55 3
  • .changeset/chubby-bushes-melt.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +'@astrojs/internal-helpers': patch
    +---
    +
    +Handle backslash and encoded backslash in isRemotePath
    
  • packages/astro/test/core-image.test.js+9 2 modified
    @@ -1355,8 +1355,15 @@ describe('astro:image', () => {
     				let response = await app.render(request);
     				const body = await response.text();
     
    -				assert.equal(response.status, 500);
    -				assert.equal(body.includes('Internal Server Error'), true);
    +				// Most paths are malformed local paths (500), but some backslash patterns
    +				// are now correctly detected as remote and get 403
    +				const { isRemotePath } = await import('@astrojs/internal-helpers/path');
    +				const isDetectedAsRemote = isRemotePath(path);
    +				const expectedStatus = isDetectedAsRemote ? 403 : 500;
    +				const expectedBodyText = isDetectedAsRemote ? 'Forbidden' : 'Internal Server Error';
    +				
    +				assert.equal(response.status, expectedStatus, `Path "${path}" should return ${expectedStatus}`);
    +				assert.equal(body.includes(expectedBodyText), true, `Path "${path}" body should include "${expectedBodyText}"`);
     			}
     
     			// Server should still be running
    
  • packages/internal-helpers/src/path.ts+18 1 modified
    @@ -119,10 +119,27 @@ const URL_PROTOCOL_REGEX = /^(?:(?:http|ftp|https|ws):?\/\/|\/\/)/;
      * - `ws://`
      * - `//` (protocol-relative URLs)
      * - `data:` (base64 images)
    + * - Backslash variants (e.g., `\\example.com`) that could normalize to remote URLs
    + * - URL-encoded backslash variants (e.g., `%5C%5Cexample.com`)
      * @param src
      */
     export function isRemotePath(src: string) {
    -	return URL_PROTOCOL_REGEX.test(src) || src.startsWith('data:');
    +	// First decode any URL-encoded backslashes
    +	const decoded = src.replace(/%5C/gi, '\\');
    +	
    +	// Check for any backslash at the start (single or multiple)
    +	// These can be normalized to protocol-relative URLs
    +	if (decoded[0] === '\\') {
    +		return true;
    +	}
    +	
    +	// Check for protocols with backslashes (e.g., http:\\ or https:\\)
    +	if (/^(?:http|https|ftp|ws):\\/.test(decoded)) {
    +		return true;
    +	}
    +	
    +	// Check standard URL patterns
    +	return URL_PROTOCOL_REGEX.test(decoded) || decoded.startsWith('data:');
     }
     
     export function slash(path: string) {
    
  • packages/internal-helpers/test/path.test.js+23 0 modified
    @@ -32,5 +32,28 @@ describe('isRemotePath', () => {
     			'should not be a remote path',
     		);
     		assert.equal(isRemotePath('mailto:example@example.com'), false, 'should not be a remote path');
    +		
    +		// Backslash bypass attempts - these SHOULD be treated as remote paths
    +		// to prevent SSRF via URL normalization in downstream code
    +		assert.equal(isRemotePath('\\\\example.com/foo/bar.js'), true, 'double backslash should be detected as remote');
    +		assert.equal(isRemotePath('\\example.com/foo/bar.js'), true, 'single backslash should be detected as remote');
    +		assert.equal(isRemotePath('\\\\\\example.com/foo/bar.js'), true, 'triple backslash should be detected as remote');
    +		
    +		// Encoded backslash attempts - these should also be caught
    +		assert.equal(isRemotePath('%5C%5Cexample.com/foo/bar.js'), true, 'encoded double backslash should be detected as remote');
    +		assert.equal(isRemotePath('%5Cexample.com/foo/bar.js'), true, 'encoded single backslash should be detected as remote');
    +		
    +		// Mixed forward and backslashes
    +		assert.equal(isRemotePath('\\//example.com/foo/bar.js'), true, 'mixed backslash-forward should be detected as remote');
    +		assert.equal(isRemotePath('/\\example.com/foo/bar.js'), false, 'forward-backslash in path should not be remote');
    +		
    +		// Backslashes with protocols (malformed but could be normalized)
    +		assert.equal(isRemotePath('http:\\\\example.com/foo/bar.js'), true, 'http with backslashes should be detected as remote');
    +		assert.equal(isRemotePath('https:\\\\example.com/foo/bar.js'), true, 'https with backslashes should be detected as remote');
    +		assert.equal(isRemotePath('http:\\example.com/foo/bar.js'), true, 'http with single backslash should be detected as remote');
    +		
    +		// Other backslash edge cases
    +		assert.equal(isRemotePath('\\raw.githubusercontent.com/test.svg'), true, 'backslash with real domain should be detected as remote');
    +		assert.equal(isRemotePath('\\\\raw.githubusercontent.com/test.svg'), true, 'double backslash with real domain should be detected as remote');
     	});
     });
    
9ecf3598e2b2

Merge commit from fork

https://github.com/withastro/astroAlexander NiebuhrAug 27, 2025via ghsa
6 files changed · +90 10
  • .changeset/evil-rabbits-flow.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +'@astrojs/cloudflare': patch
    +---
    +
    +Improves the image proxy endpoint when using the default compile option to adhere to user configuration regarding the allowed remote domains
    
  • packages/integrations/cloudflare/src/entrypoints/image-endpoint.ts+13 0 modified
    @@ -1,4 +1,8 @@
    +// @ts-expect-error
    +import { imageConfig } from 'astro:assets';
    +import { isRemotePath } from '@astrojs/internal-helpers/path';
     import type { APIRoute } from 'astro';
    +import { isRemoteAllowed } from 'astro/assets/utils';
     
     export const prerender = false;
     
    @@ -11,5 +15,14 @@ export const GET: APIRoute = (ctx) => {
     		});
     	}
     
    +	if (isRemotePath(href)) {
    +		if (isRemoteAllowed(href, imageConfig) === false) {
    +			return new Response('Forbidden', { status: 403 });
    +		} else {
    +			// Redirect here because it is safer than a proxy, remote image will be served by remote domain and not own domain
    +			return Response.redirect(href, 302);
    +		}
    +	}
    +
     	return fetch(new URL(href, ctx.url.origin));
     };
    
  • packages/integrations/cloudflare/test/compile-image-service.test.js+68 0 added
    @@ -0,0 +1,68 @@
    +import * as assert from 'node:assert/strict';
    +import { after, before, describe, it } from 'node:test';
    +import { fileURLToPath } from 'node:url';
    +import { astroCli, wranglerCli } from './_test-utils.js';
    +
    +const root = new URL('./fixtures/compile-image-service/', import.meta.url);
    +
    +describe('CompileImageService', () => {
    +	let wrangler;
    +	before(async () => {
    +		await astroCli(fileURLToPath(root), 'build');
    +
    +		wrangler = wranglerCli(fileURLToPath(root));
    +		await new Promise((resolve) => {
    +			wrangler.stdout.on('data', (data) => {
    +				// console.log('[stdout]', data.toString());
    +				if (data.toString().includes('http://127.0.0.1:8788')) resolve();
    +			});
    +			wrangler.stderr.on('data', (_data) => {
    +				// console.log('[stderr]', data.toString());
    +			});
    +		});
    +	});
    +
    +	after(() => {
    +		wrangler.kill();
    +	});
    +
    +	it('forbids http://', async () => {
    +		const res = await fetch('http://127.0.0.1:8788/_image?href=http://placehold.co/600x400');
    +		const html = await res.text();
    +		const status = res.status;
    +		assert.equal(html, 'Forbidden');
    +		assert.equal(status, 403);
    +	});
    +
    +	it('forbids https://', async () => {
    +		const res = await fetch('http://127.0.0.1:8788/_image?href=https://placehold.co/600x400');
    +		const html = await res.text();
    +		const status = res.status;
    +		assert.equal(html, 'Forbidden');
    +		assert.equal(status, 403);
    +	});
    +
    +	it('forbids //', async () => {
    +		const res = await fetch('http://127.0.0.1:8788/_image?href=//placehold.co/600x400');
    +		const html = await res.text();
    +		const status = res.status;
    +		assert.equal(html, 'Forbidden');
    +		assert.equal(status, 403);
    +	});
    +
    +	it('allows trusted with redirect', async () => {
    +		const res = await fetch('http://127.0.0.1:8788/_image?href=https://astro.build/_astro/HeroBackground.B0iWl89K_2hpsgp.webp', { redirect: "manual" })
    +		const header = res.headers.get("location")
    +		const status = res.status;
    +		assert.equal(header, "https://astro.build/_astro/HeroBackground.B0iWl89K_2hpsgp.webp")
    +		assert.equal(status, 302)
    +	})
    +
    +	it('allows local', async () => {
    +		const res = await fetch('http://127.0.0.1:8788/_image?href=/_astro/placeholder.gLBdjEDe.jpg');
    +		const blob = await res.blob();
    +		const status = res.status;
    +		assert.equal(blob.type, 'image/jpeg');
    +		assert.equal(status, 200);
    +	});
    +});
    
  • packages/integrations/cloudflare/test/fixtures/compile-image-service/astro.config.mjs+3 0 modified
    @@ -6,4 +6,7 @@ export default defineConfig({
     		imageService: 'compile',
     	}),
     	output: 'static',
    +	image: {
    +		domains: ["astro.build"]
    +	}
     });
    
  • packages/integrations/cloudflare/test/fixtures/compile-image-service/src/content/blog/post/index.md+1 1 modified
    @@ -2,4 +2,4 @@
     image: './placeholder.jpg'
     ---
     
    -![placeholder](./placeholder.jpg)
    +![placeholder](./placeholder.jpg)
    \ No newline at end of file
    
  • packages/integrations/cloudflare/test/fixtures/compile-image-service/src/pages/blog/[...slug].astro+0 9 modified
    @@ -1,5 +1,4 @@
     ---
    -import { Image } from "astro:assets";
     import { getEntry, type CollectionEntry } from "astro:content";
     
     export const prerender = false;
    @@ -21,14 +20,6 @@ const { Content } = await post.render();
         <title>Document</title>
       </head>
       <body>
    -    <div class="aspect-video w-full overflow-hidden flex items-end rounded-lg">
    -      <Image
    -        class="aspect-[4/3] object-cover object-left w-full"
    -        src={post.data.image}
    -        alt=""
    -      />
    -    </div>
    -
         <Content />
       </body>
     </html>
    

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

5

News mentions

0

No linked articles in our index yet.