---
id: "GHSA-m8p5-r7qv-cx4c"
page: "https://infrastack.ai/live/GHSA-m8p5-r7qv-cx4c"
kind: "vulnerability"
aliases: ["GHSA-m8p5-r7qv-cx4c"]
title: "Full-read SSRF in the default `/_image` endpoint via the same Host header"
severity: "medium"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "@astrojs/node", "fixed": ["11.1.7"], "affected": "<= 11.1.6", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["astro", "caching", "cwe-918", "node", "package", "password-reset", "self-hosted"]
not_affected_if: ["not-using-node-adapter"]
published_at: "2026-10-12T18:20:10+00:00"
first_seen_at: "2026-11-09T22:53:17+00:00"
updated_at: "2026-11-09T22:59:08+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/withastro/astro/security/advisories/GHSA-m8p5-r7qv-cx4c", "first_seen_at": "2026-11-09T22:53:17+00:00"}]
exploit_sources: []
---

# Full-read SSRF in the default `/_image` endpoint via the same Host header

Link for people: https://infrastack.ai/live/GHSA-m8p5-r7qv-cx4c (this .md is for agents)

The @astrojs/node adapter builds the request URL straight from the Host header without checking it against security.allowedDomains. An attacker can send a crafted Host header so Astro.url and every absolute link built from it point to an attacker-chosen host, enabling password-reset link poisoning and cache poisoning.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:@astrojs/node affected <= 11.1.6 fixed 11.1.7

## How to check

- Run `npm ls @astrojs/node` and confirm the installed version is `11.1.6` or lower.
- Search your pages and endpoints for uses of `Astro.url` that build absolute links, canonical tags, or reset and confirmation URLs.
- Check whether a shared cache or CDN keys on path but not on `Host`.

## Fix

- `@astrojs/node` `11.1.7`

## Timeline

- 2026-11-09T22:53:17+00:00 ghsa-repo https://github.com/withastro/astro/security/advisories/GHSA-m8p5-r7qv-cx4c

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/withastro/astro/security/advisories/GHSA-m8p5-r7qv-cx4c

## From the sources

### Root cause

`astro/app/node` exports two request builders with opposite security postures:

- `createRequest()` (line 123) validates the `Host` header against `security.allowedDomains` and
  **falls back to `localhost`** when it does not match, with the comment
  *"Otherwise, fall back to 'localhost' to prevent SSRF attacks."*
- `createRequestFromNodeRequest()` (line 51) takes `req.headers.host` **verbatim** as the URL
  authority. No validation of any kind.

`@astrojs/node` uses the unvalidated one on every request, in both serving modes:

```
packages/integrations/node/src/serve-app.ts:99    createRequestFromNodeRequest(req, {...})
packages/integrations/node/src/serve-static.ts:73  createRequestFromNodeRequest(req, {...})
```

The doc comment on `createRequestFromNodeRequest` justifies the omission:

> Internal version of `createRequest` that skips X-Forwarded-* header validation.
> Forwarded headers are resolved later inside `FetchState`, so doing it here too would
> duplicate work on every request.

That is true for `X-Forwarded-*` but not for `Host`. `FetchState.#applyForwardedHeaders()`
only runs when `allowedDomains` is configured, and it only ever inspects
`x-forwarded-proto` / `x-forwarded-host` / `x-forwarded-port`. It returns early when none
are present, leaving `this.url` on the raw `Host`-derived origin. Nothing downstream
re-validates `Host`.

Commit `da8b5735` ("Validate X-Forwarded-Host against allowedDomains in
createRequestFromNodeRequest") hardened the forwarded header in this exact function
but left `Host` untouched.

### Why this contradicts the documentation

`security.allowedDomains` is documented (`packages/astro/src/types/public/config.ts:700`) as:

> This prevents host header injection attacks where malicious actors can manipulate the
> `Astro.url` value by sending crafted `X-Forwarded-Host` headers.

`Astro.url` is manipulable by the plain `Host` header, which `allowedDomains` never checks.

### Proof

Config: default `@astrojs/node` standalone, `security.allowedDomains: [{ hostname: 'example.com', protocol: 'http' }]`.

Page:
```astro
---
const origin = Astro.url.origin;
const absolute = new URL('/reset?token=abc', Astro.url).href;
---
<p>Astro.url.origin = {origin}</p>
<p>absolute link    = {absolute}</p>
```

```
$ curl -s http://127.0.0.1:4321/whoami
<p>Astro.url.origin = http://127.0.0.1:4321</p>
<p>absolute link    = http://127.0.0.1:4321/reset?token=abc</p>

$ curl -s -H 'Host: attacker.example.net' http://127.0.0.1:4321/whoami
<p>Astro.url.origin = http://attacker.example.net</p>
<p>absolute link    = http://attacker.example.net/reset?token=abc</p>
```

### Impact

Every absolute URL an application builds from `Astro.url` (the documented way to do it)
points at an attacker-chosen host. Standard consequences: password-reset / email-confirmation
link poisoning, poisoned canonical and `og:` tags, and cache poisoning where a shared cache
keys on path but not `Host`. The failure is silent — `allowedDomains` gives operators the
impression the value is validated.

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
