astro allows bypass of image proxy domain validation leading to SSRF and potential XSS
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.
| Package | Affected versions | Patched versions |
|---|---|---|
astronpm | >= 5.13.4, < 5.13.10 | 5.13.10 |
Affected products
1Patches
21e2499e8ea83fix(internal-helpers): improve isRemotePath to handle backslash URLs (#14408)
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'); }); });
9ecf3598e2b2Merge commit from fork
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' --- - + \ 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- github.com/advisories/GHSA-qcpr-679q-rhm2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-59837ghsaADVISORY
- github.com/withastro/astro/commit/1e2499e8ea83ebfa233a18a7499e1ccf169e56f4ghsax_refsource_MISCWEB
- github.com/withastro/astro/commit/9ecf3598e2b29dd74614328fde3047ea90e67252ghsax_refsource_MISCWEB
- github.com/withastro/astro/security/advisories/GHSA-qcpr-679q-rhm2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.