Astro's `X-Forwarded-Host` is reflected with no validation
Description
Astro is a web framework. Prior to version 5.14.2, 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 theoretically possible 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. This could affect anyone using Astro in an on-demand/dynamic rendering mode behind a caching proxy. Version 5.14.2 contains a fix for the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
astronpm | < 5.14.3 | 5.14.3 |
Affected products
1Patches
16ee63bfac485Merge commit from fork
11 files changed · +210 −5
.changeset/secure-forwarded-host-validation.md+32 −0 added@@ -0,0 +1,32 @@ +--- +'astro': patch +--- + +Adds `security.allowedDomains` configuration to validate `X-Forwarded-Host` headers in SSR + +The `X-Forwarded-Host` header will now only be trusted if it matches one of the configured allowed host patterns. This prevents [host header injection attacks](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection) that can lead to cache poisoning and other security vulnerabilities. + +Configure allowed host patterns to enable `X-Forwarded-Host` support: + +```js +// astro.config.mjs +export default defineConfig({ + output: 'server', + adapter: node(), + security: { + allowedDomains: [ + { hostname: 'example.com' }, + { hostname: '*.example.com' }, + { hostname: 'cdn.example.com', port: '443' } + ] + } +}) +``` + +The patterns support wildcards (`*` and `**`) for flexible hostname matching and can optionally specify protocol and port. + +### Breaking change + +Previously, `Astro.url` would reflect the value of the `X-Forwarded-Host` header. While this header is commonly used by reverse proxies like Nginx to communicate the original host, it can be sent by any client, potentially allowing malicious actors to poison caches with incorrect URLs. + +If you were relying on `X-Forwarded-Host` support, add `security.allowedDomains` to your configuration to restore this functionality securely. When `allowedDomains` is not configured, `X-Forwarded-Host` headers are now ignored by default.
packages/astro/src/container/index.ts+2 −0 modified@@ -158,6 +158,7 @@ function createManifest( inlinedScripts: manifest?.inlinedScripts ?? new Map(), i18n: manifest?.i18n, checkOrigin: false, + allowedDomains: manifest?.allowedDomains ?? [], middleware: manifest?.middleware ?? middlewareInstance, key: createKey(), csp: manifest?.csp, @@ -247,6 +248,7 @@ type AstroContainerManifest = Pick< | 'outDir' | 'cacheDir' | 'csp' + | 'allowedDomains' >; type AstroContainerConstructor = {
packages/astro/src/core/app/index.ts+42 −1 modified@@ -3,6 +3,7 @@ import { hasFileExtension, isInternalPath, } from '@astrojs/internal-helpers/path'; +import { matchPattern, type RemotePattern } from '../../assets/utils/remotePattern.js'; import { normalizeTheLocale } from '../../i18n/index.js'; import type { RoutesList } from '../../types/astro.js'; import type { RouteData, SSRManifest } from '../../types/public/internal.js'; @@ -137,6 +138,38 @@ export class App { return this.#adapterLogger; } + getAllowedDomains() { + return this.#manifest.allowedDomains; + } + + protected get manifest(): SSRManifest { + return this.#manifest; + } + + protected matchesAllowedDomains(forwardedHost: string, protocol?: string): boolean { + return App.validateForwardedHost(forwardedHost, this.#manifest.allowedDomains, protocol); + } + + static validateForwardedHost( + forwardedHost: string, + allowedDomains?: Partial<RemotePattern>[], + protocol?: string + ): boolean { + if (!allowedDomains || allowedDomains.length === 0) { + return false; + } + + try { + const testUrl = new URL(`${protocol || 'https'}://${forwardedHost}`); + return allowedDomains.some((pattern) => { + return matchPattern(testUrl, pattern); + }); + } catch { + // Invalid URL + return false; + } + } + /** * Creates a pipeline by reading the stored manifest * @@ -235,7 +268,7 @@ export class App { this.#manifest.i18n.strategy === 'domains-prefix-always-no-redirect') ) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host - let host = request.headers.get('X-Forwarded-Host'); + let forwardedHost = request.headers.get('X-Forwarded-Host'); // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto let protocol = request.headers.get('X-Forwarded-Proto'); if (protocol) { @@ -245,6 +278,14 @@ export class App { // we fall back to the protocol of the request protocol = url.protocol; } + + // Validate X-Forwarded-Host against allowedDomains if configured + if (forwardedHost && !this.matchesAllowedDomains(forwardedHost, protocol)) { + // If not allowed, ignore the X-Forwarded-Host header + forwardedHost = null; + } + + let host = forwardedHost; if (!host) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host host = request.headers.get('Host');
packages/astro/src/core/app/node.ts+18 −3 modified@@ -2,6 +2,8 @@ import fs from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { Http2ServerResponse } from 'node:http2'; import type { Socket } from 'node:net'; +// matchPattern is used in App.validateForwardedHost, no need to import here +import type { RemotePattern } from '../../types/public/config.js'; import type { RouteData } from '../../types/public/internal.js'; import { clientAddressSymbol, nodeRequestAbortControllerCleanupSymbol } from '../constants.js'; import { deserializeManifest } from './common.js'; @@ -31,6 +33,7 @@ export class NodeApp extends App { if (!(req instanceof Request)) { req = NodeApp.createRequest(req, { skipBody: true, + allowedDomains: this.manifest.allowedDomains, }); } return super.match(req, allowPrerenderedRoutes); @@ -47,7 +50,9 @@ export class NodeApp extends App { maybeLocals?: object, ) { if (!(req instanceof Request)) { - req = NodeApp.createRequest(req); + req = NodeApp.createRequest(req, { + allowedDomains: this.manifest.allowedDomains, + }); } // @ts-expect-error The call would have succeeded against the implementation, but implementation signatures of overloads are not externally visible. return super.render(req, routeDataOrOptions, maybeLocals); @@ -66,7 +71,10 @@ export class NodeApp extends App { * }) * ``` */ - static createRequest(req: NodeRequest, { skipBody = false } = {}): Request { + static createRequest( + req: NodeRequest, + { skipBody = false, allowedDomains = [] }: { skipBody?: boolean; allowedDomains?: Partial<RemotePattern>[] } = {}, + ): Request { const controller = new AbortController(); const isEncrypted = 'encrypted' in req.socket && req.socket.encrypted; @@ -88,8 +96,15 @@ export class NodeApp extends App { const protocol = forwardedProtocol ?? providedProtocol; // @example "example.com,www2.example.com" => "example.com" - const forwardedHostname = getFirstForwardedValue(req.headers['x-forwarded-host']); + let forwardedHostname = getFirstForwardedValue(req.headers['x-forwarded-host']); const providedHostname = req.headers.host ?? req.headers[':authority']; + + // Validate X-Forwarded-Host against allowedDomains if configured + if (forwardedHostname && !App.validateForwardedHost(forwardedHostname, allowedDomains, forwardedProtocol ?? providedProtocol)) { + // If not allowed, ignore the X-Forwarded-Host header + forwardedHostname = undefined; + } + const hostname = forwardedHostname ?? providedHostname; // @example "443,8080,80" => "443"
packages/astro/src/core/app/types.ts+2 −0 modified@@ -7,6 +7,7 @@ import type { AstroConfig, CspAlgorithm, Locales, + RemotePattern, ResolvedSessionConfig, } from '../../types/public/config.js'; import type { @@ -85,6 +86,7 @@ export type SSRManifest = { middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance; actions?: () => Promise<SSRActions> | SSRActions; checkOrigin: boolean; + allowedDomains?: Partial<RemotePattern>[]; sessionConfig?: ResolvedSessionConfig<any>; cacheDir: string | URL; srcDir: string | URL;
packages/astro/src/core/build/plugins/plugin-manifest.ts+1 −0 modified@@ -367,6 +367,7 @@ async function buildManifest( buildFormat: settings.config.build.format, checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, + allowedDomains: settings.config.security?.allowedDomains, serverIslandNameMap: Array.from(settings.serverIslandNameMap), key: encodedKey, sessionConfig: settings.config.session,
packages/astro/src/core/config/schemas/base.ts+11 −0 modified@@ -89,6 +89,7 @@ export const ASTRO_CONFIG_DEFAULTS = { redirects: {}, security: { checkOrigin: true, + allowedDomains: [], }, env: { schema: {}, @@ -422,6 +423,16 @@ export const AstroConfigSchema = z.object({ security: z .object({ checkOrigin: z.boolean().default(ASTRO_CONFIG_DEFAULTS.security.checkOrigin), + allowedDomains: z + .array( + z.object({ + hostname: z.string().optional(), + protocol: z.string().optional(), + port: z.string().optional(), + }), + ) + .optional() + .default(ASTRO_CONFIG_DEFAULTS.security.allowedDomains), }) .optional() .default(ASTRO_CONFIG_DEFAULTS.security),
packages/astro/src/types/public/config.ts+41 −0 modified@@ -26,6 +26,8 @@ export type { AstroFontProvider as FontProvider }; export type { CspAlgorithm }; +export type { RemotePattern }; + type NormalizeLocales<T extends Locales> = { [K in keyof T]: T[K] extends string ? T[K] @@ -589,6 +591,45 @@ export interface AstroUserConfig< */ checkOrigin?: boolean; + + /** + * @docs + * @name security.allowedDomains + * @type {RemotePattern[]} + * @default `[]` + * @version 5.15.0 + * @description + * + * Defines a list of permitted host patterns for incoming requests when using SSR. When configured, Astro will validate the `X-Forwarded-Host` header + * against these patterns for security. If the header doesn't match any allowed pattern, the header is ignored and the request's original host is used instead. + * + * This prevents host header injection attacks where malicious actors can manipulate the `Astro.url` value by sending crafted `X-Forwarded-Host` headers. + * + * Each pattern can specify `protocol`, `hostname`, and `port`. All three are validated if provided. + * The patterns support wildcards for flexible hostname matching: + * + * ```js + * { + * security: { + * // Example: Allow any subdomain of example.com on https + * allowedDomains: [ + * { + * hostname: '**.example.com', + * protocol: 'https' + * }, + * { + * hostname: 'staging.myapp.com', + * protocol: 'https', + * port: '443' + * } + * ] + * } + * } + * ``` + * + * When not configured, `X-Forwarded-Host` headers are not trusted and will be ignored. + */ + allowedDomains?: Partial<RemotePattern>[]; }; /**
packages/integrations/node/src/serve-app.ts+3 −1 modified@@ -36,7 +36,9 @@ export function createAppHandler(app: NodeApp, options: Options): RequestHandler return async (req, res, next, locals) => { let request: Request; try { - request = NodeApp.createRequest(req); + request = NodeApp.createRequest(req, { + allowedDomains: app.getAllowedDomains() + }); } catch (err) { logger.error(`Could not render ${req.url}`); console.error(err);
packages/integrations/node/test/fixtures/url/astro.config.mjs+14 −0 added@@ -0,0 +1,14 @@ +import { defineConfig } from 'astro/config'; +import nodejs from '@astrojs/node'; + +export default defineConfig({ + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + security: { + allowedDomains: [ + { + hostname: 'abc.xyz' + } + ] + } +}); \ No newline at end of file
packages/integrations/node/test/url.test.js+44 −0 modified@@ -80,6 +80,7 @@ describe('URL', () => { 'X-Forwarded-Proto': 'https', 'X-Forwarded-Host': 'abc.xyz', 'X-Forwarded-Port': '444', + 'Host': 'localhost:3000', }, url: '/', }); @@ -112,4 +113,47 @@ describe('URL', () => { assert.equal($('body').text(), 'https://abc.xyz:444/'); }); + + it('ignores X-Forwarded-Host when no allowedDomains configured', async () => { + const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); + const { req, res, text } = createRequestAndResponse({ + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'malicious.example.com', + 'Host': 'legitimate.example.com', + }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + const $ = cheerio.load(html); + + // Should use the Host header, not X-Forwarded-Host when allowedDomains is not configured + assert.equal($('body').text(), 'https://legitimate.example.com/'); + }); + + it('accepts any port when port not specified in allowedDomains', async () => { + const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); + const { req, res, text } = createRequestAndResponse({ + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'abc.xyz:8080', + 'Host': 'localhost:3000', + }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + const $ = cheerio.load(html); + + // When no port is specified in allowedDomains pattern, any port is accepted + // This validates that port validation works (it's checking and passing) + assert.equal($('body').text(), 'https://abc.xyz:8080/'); + }); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-5ff5-9fcw-vg88ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-61925ghsaADVISORY
- github.com/withastro/astro/commit/6ee63bfac4856f21b4d4633021b3d2ee059e553fghsaWEB
- github.com/withastro/astro/security/advisories/GHSA-5ff5-9fcw-vg88ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.