Astro Cloudflare adapter is vulnerable to Server-Side Request Forgery via /_image endpoint
Description
Astro is a web framework for content-driven websites. Versions 11.0.3 through 12.6.5 are vulnerable to SSRF when using Astro's Cloudflare adapter. When configured with output: 'server' while using the default imageService: 'compile', the generated image optimization endpoint doesn't check the URLs it receives, allowing content from unauthorized third-party domains to be served. a A bug in impacted versions of the @astrojs/cloudflare adapter for deployment on Cloudflare’s infrastructure, allows an attacker to bypass the third-party domain restrictions and serve any content from the vulnerable origin. This issue is fixed in version 12.6.6.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@astrojs/cloudflarenpm | >= 11.0.3, < 12.6.6 | 12.6.6 |
Affected products
1Patches
19ecf3598e2b2Merge 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
4- github.com/advisories/GHSA-qpr4-c339-7vq8ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-58179ghsaADVISORY
- github.com/withastro/astro/commit/9ecf3598e2b29dd74614328fde3047ea90e67252ghsax_refsource_MISCWEB
- github.com/withastro/astro/security/advisories/GHSA-qpr4-c339-7vq8ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.