Skip to content

Commit dafbb1b

Browse files
Prevent cache poisoning in x-forwarded headers (#14743)
* Restrict X-Forwarded-Proto and X-Forwarded-Port * Fix X-Forwarded header security vulnerabilities - Sanitize hostnames to reject paths and prevent path injection - Validate X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port headers - Add strict rejection for invalid hostnames (those with path separators) - Implement single sanitizeHost() function in App class, used by both validateForwardedHeaders() and node.ts - Add comprehensive security tests for header validation * Fix path injection and port matching bugs in header validation - Reject both forward and backward slashes in hostnames using single regex - Fix allowedDomains port matching by validating full hostname:port combo instead of just hostname - Add test for X-Forwarded-Host with embedded port in allowedDomains pattern * changeset and build * fix: validate X-Forwarded headers with port pattern matching Fixes protocol validation to accept http/https when allowedDomains exist but lack protocol patterns. Restructures port/host validation to validate port first, then include it when validating host against patterns. Properly extracts hostname without port to avoid duplication when combining with X-Forwarded-Port. * Update .changeset/secure-headers.md Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev> --------- Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>
1 parent 0d84321 commit dafbb1b

6 files changed

Lines changed: 347 additions & 62 deletions

File tree

.changeset/secure-headers.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Improves `X-Forwarded` header validation to prevent cache poisoning and header injection attacks. Now properly validates `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-Port` headers against configured `allowedDomains` patterns, rejecting malformed or suspicious values. This is especially important when running behind a reverse proxy or load balancer.

packages/astro/src/core/app/index.ts

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,98 @@ export class App {
174174
}
175175
}
176176

177+
/**
178+
* Validate a hostname by rejecting any with path separators.
179+
* Prevents path injection attacks. Invalid hostnames return undefined.
180+
*/
181+
static sanitizeHost(hostname: string | undefined): string | undefined {
182+
if (!hostname) return undefined;
183+
// Reject any hostname containing path separators - they're invalid
184+
if (/[/\\]/.test(hostname)) return undefined;
185+
return hostname;
186+
}
187+
188+
/**
189+
* Validate forwarded headers (proto, host, port) against allowedDomains.
190+
* Returns validated values or undefined for rejected headers.
191+
* Uses strict defaults: http/https only for proto, rejects port if not in allowedDomains.
192+
*/
193+
static validateForwardedHeaders(
194+
forwardedProtocol?: string,
195+
forwardedHost?: string,
196+
forwardedPort?: string,
197+
allowedDomains?: Partial<RemotePattern>[],
198+
): { protocol?: string; host?: string; port?: string } {
199+
const result: { protocol?: string; host?: string; port?: string } = {};
200+
201+
// Validate protocol
202+
if (forwardedProtocol) {
203+
if (allowedDomains && allowedDomains.length > 0) {
204+
const hasProtocolPatterns = allowedDomains.some(
205+
(pattern) => pattern.protocol !== undefined,
206+
);
207+
if (hasProtocolPatterns) {
208+
// Validate against allowedDomains patterns
209+
try {
210+
const testUrl = new URL(`${forwardedProtocol}://example.com`);
211+
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
212+
if (isAllowed) {
213+
result.protocol = forwardedProtocol;
214+
}
215+
} catch {
216+
// Invalid protocol, omit from result
217+
}
218+
} else if (/^https?$/.test(forwardedProtocol)) {
219+
// allowedDomains exist but no protocol patterns, allow http/https
220+
result.protocol = forwardedProtocol;
221+
}
222+
} else if (/^https?$/.test(forwardedProtocol)) {
223+
// No allowedDomains, only allow http/https
224+
result.protocol = forwardedProtocol;
225+
}
226+
}
227+
228+
// Validate port first
229+
if (forwardedPort && allowedDomains && allowedDomains.length > 0) {
230+
const hasPortPatterns = allowedDomains.some((pattern) => pattern.port !== undefined);
231+
if (hasPortPatterns) {
232+
// Validate against allowedDomains patterns
233+
const isAllowed = allowedDomains.some((pattern) => pattern.port === forwardedPort);
234+
if (isAllowed) {
235+
result.port = forwardedPort;
236+
}
237+
}
238+
// If no port patterns, reject the header (strict security default)
239+
}
240+
241+
// Validate host (extract port from hostname for validation)
242+
// Reject empty strings and sanitize to prevent path injection
243+
if (forwardedHost && forwardedHost.length > 0 && allowedDomains && allowedDomains.length > 0) {
244+
const protoForValidation = result.protocol || 'https';
245+
const sanitized = App.sanitizeHost(forwardedHost);
246+
if (sanitized) {
247+
try {
248+
// Extract hostname without port for validation
249+
const hostnameOnly = sanitized.split(':')[0];
250+
// Use full hostname:port for validation so patterns with ports match correctly
251+
// Include validated port if available, otherwise use port from forwardedHost if present
252+
const portFromHost = sanitized.includes(':') ? sanitized.split(':')[1] : undefined;
253+
const portForValidation = result.port || portFromHost;
254+
const hostWithPort = portForValidation ? `${hostnameOnly}:${portForValidation}` : hostnameOnly;
255+
const testUrl = new URL(`${protoForValidation}://${hostWithPort}`);
256+
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
257+
if (isAllowed) {
258+
result.host = sanitized;
259+
}
260+
} catch {
261+
// Invalid host, omit from result
262+
}
263+
}
264+
}
265+
266+
return result;
267+
}
268+
177269
/**
178270
* Creates a pipeline by reading the stored manifest
179271
*
@@ -271,29 +363,19 @@ export class App {
271363
this.#manifest.i18n.strategy === 'domains-prefix-other-locales' ||
272364
this.#manifest.i18n.strategy === 'domains-prefix-always-no-redirect')
273365
) {
274-
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host
275-
let forwardedHost = request.headers.get('X-Forwarded-Host');
276-
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
277-
let protocol = request.headers.get('X-Forwarded-Proto');
278-
if (protocol) {
279-
// this header doesn't have a colon at the end, so we add to be in line with URL#protocol, which does have it
280-
protocol = protocol + ':';
281-
} else {
282-
// we fall back to the protocol of the request
283-
protocol = url.protocol;
284-
}
366+
// Validate forwarded headers
367+
const validated = App.validateForwardedHeaders(
368+
request.headers.get('X-Forwarded-Proto') ?? undefined,
369+
request.headers.get('X-Forwarded-Host') ?? undefined,
370+
request.headers.get('X-Forwarded-Port') ?? undefined,
371+
this.#manifest.allowedDomains,
372+
);
285373

286-
// Validate X-Forwarded-Host against allowedDomains if configured
287-
if (forwardedHost && !this.matchesAllowedDomains(forwardedHost, protocol?.replace(':', ''))) {
288-
// If not allowed, ignore the X-Forwarded-Host header
289-
forwardedHost = null;
290-
}
374+
// Build protocol with fallback
375+
let protocol = validated.protocol ? validated.protocol + ':' : url.protocol;
291376

292-
let host = forwardedHost;
293-
if (!host) {
294-
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host
295-
host = request.headers.get('Host');
296-
}
377+
// Build host with fallback
378+
let host = validated.host ?? request.headers.get('Host');
297379
// If we don't have a host and a protocol, it's impossible to proceed
298380
if (host && protocol) {
299381
// The header might have a port in their name, so we remove it

packages/astro/src/core/app/node.ts

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import fs from 'node:fs';
22
import type { IncomingMessage, ServerResponse } from 'node:http';
33
import { Http2ServerResponse } from 'node:http2';
44
import type { Socket } from 'node:net';
5-
// matchPattern is used in App.validateForwardedHost, no need to import here
65
import type { RemotePattern } from '../../types/public/config.js';
76
import type { RouteData } from '../../types/public/internal.js';
87
import { clientAddressSymbol, nodeRequestAbortControllerCleanupSymbol } from '../constants.js';
@@ -90,35 +89,25 @@ export class NodeApp extends App {
9089
.map((e) => e.trim())?.[0];
9190
};
9291

93-
// Get the used protocol between the end client and first proxy.
94-
// NOTE: Some proxies append values with spaces and some do not.
95-
// We need to handle it here and parse the header correctly.
96-
// @example "https, http,http" => "http"
97-
const forwardedProtocol = getFirstForwardedValue(req.headers['x-forwarded-proto']);
9892
const providedProtocol = isEncrypted ? 'https' : 'http';
99-
const protocol = forwardedProtocol ?? providedProtocol;
100-
101-
// @example "example.com,www2.example.com" => "example.com"
102-
let forwardedHostname = getFirstForwardedValue(req.headers['x-forwarded-host']);
10393
const providedHostname = req.headers.host ?? req.headers[':authority'];
10494

105-
// Validate X-Forwarded-Host against allowedDomains if configured
106-
if (
107-
forwardedHostname &&
108-
!App.validateForwardedHost(
109-
forwardedHostname,
110-
allowedDomains,
111-
forwardedProtocol ?? providedProtocol,
112-
)
113-
) {
114-
// If not allowed, ignore the X-Forwarded-Host header
115-
forwardedHostname = undefined;
116-
}
117-
118-
const hostname = forwardedHostname ?? providedHostname;
95+
// Validate forwarded headers
96+
// NOTE: Header values may have commas/spaces from proxy chains, extract first value
97+
const validated = App.validateForwardedHeaders(
98+
getFirstForwardedValue(req.headers['x-forwarded-proto']),
99+
getFirstForwardedValue(req.headers['x-forwarded-host']),
100+
getFirstForwardedValue(req.headers['x-forwarded-port']),
101+
allowedDomains,
102+
);
119103

120-
// @example "443,8080,80" => "443"
121-
const port = getFirstForwardedValue(req.headers['x-forwarded-port']);
104+
const protocol = validated.protocol ?? providedProtocol;
105+
// validated.host is already sanitized, only sanitize providedHostname
106+
const sanitizedProvidedHostname = App.sanitizeHost(
107+
typeof providedHostname === 'string' ? providedHostname : undefined,
108+
);
109+
const hostname = validated.host ?? sanitizedProvidedHostname;
110+
const port = validated.port;
122111

123112
let url: URL;
124113
try {

0 commit comments

Comments
 (0)