Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/secure-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

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.
124 changes: 103 additions & 21 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,98 @@ export class App {
}
}

/**
* Validate a hostname by rejecting any with path separators.
* Prevents path injection attacks. Invalid hostnames return undefined.
*/
static sanitizeHost(hostname: string | undefined): string | undefined {
if (!hostname) return undefined;
// Reject any hostname containing path separators - they're invalid
if (/[/\\]/.test(hostname)) return undefined;
return hostname;
}

/**
* Validate forwarded headers (proto, host, port) against allowedDomains.
* Returns validated values or undefined for rejected headers.
* Uses strict defaults: http/https only for proto, rejects port if not in allowedDomains.
*/
static validateForwardedHeaders(
forwardedProtocol?: string,
forwardedHost?: string,
forwardedPort?: string,
allowedDomains?: Partial<RemotePattern>[],
): { protocol?: string; host?: string; port?: string } {
const result: { protocol?: string; host?: string; port?: string } = {};

// Validate protocol
if (forwardedProtocol) {
if (allowedDomains && allowedDomains.length > 0) {
const hasProtocolPatterns = allowedDomains.some(
(pattern) => pattern.protocol !== undefined,
);
if (hasProtocolPatterns) {
// Validate against allowedDomains patterns
try {
const testUrl = new URL(`${forwardedProtocol}://example.com`);
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
if (isAllowed) {
result.protocol = forwardedProtocol;
}
} catch {
// Invalid protocol, omit from result
}
} else if (/^https?$/.test(forwardedProtocol)) {
// allowedDomains exist but no protocol patterns, allow http/https
result.protocol = forwardedProtocol;
}
} else if (/^https?$/.test(forwardedProtocol)) {
// No allowedDomains, only allow http/https
result.protocol = forwardedProtocol;
}
}

// Validate port first
if (forwardedPort && allowedDomains && allowedDomains.length > 0) {
const hasPortPatterns = allowedDomains.some((pattern) => pattern.port !== undefined);
if (hasPortPatterns) {
// Validate against allowedDomains patterns
const isAllowed = allowedDomains.some((pattern) => pattern.port === forwardedPort);
if (isAllowed) {
result.port = forwardedPort;
}
}
// If no port patterns, reject the header (strict security default)
}

// Validate host (extract port from hostname for validation)
// Reject empty strings and sanitize to prevent path injection
if (forwardedHost && forwardedHost.length > 0 && allowedDomains && allowedDomains.length > 0) {
const protoForValidation = result.protocol || 'https';
const sanitized = App.sanitizeHost(forwardedHost);
if (sanitized) {
try {
// Extract hostname without port for validation
const hostnameOnly = sanitized.split(':')[0];
// Use full hostname:port for validation so patterns with ports match correctly
// Include validated port if available, otherwise use port from forwardedHost if present
const portFromHost = sanitized.includes(':') ? sanitized.split(':')[1] : undefined;
const portForValidation = result.port || portFromHost;
const hostWithPort = portForValidation ? `${hostnameOnly}:${portForValidation}` : hostnameOnly;
const testUrl = new URL(`${protoForValidation}://${hostWithPort}`);
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
if (isAllowed) {
result.host = sanitized;
}
} catch {
// Invalid host, omit from result
}
}
}

return result;
}

/**
* Creates a pipeline by reading the stored manifest
*
Expand Down Expand Up @@ -271,29 +363,19 @@ export class App {
this.#manifest.i18n.strategy === 'domains-prefix-other-locales' ||
this.#manifest.i18n.strategy === 'domains-prefix-always-no-redirect')
) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/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) {
// this header doesn't have a colon at the end, so we add to be in line with URL#protocol, which does have it
protocol = protocol + ':';
} else {
// we fall back to the protocol of the request
protocol = url.protocol;
}
// Validate forwarded headers
const validated = App.validateForwardedHeaders(
request.headers.get('X-Forwarded-Proto') ?? undefined,
request.headers.get('X-Forwarded-Host') ?? undefined,
request.headers.get('X-Forwarded-Port') ?? undefined,
this.#manifest.allowedDomains,
);

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

let host = forwardedHost;
if (!host) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host
host = request.headers.get('Host');
}
// Build host with fallback
let host = validated.host ?? request.headers.get('Host');
// If we don't have a host and a protocol, it's impossible to proceed
if (host && protocol) {
// The header might have a port in their name, so we remove it
Expand Down
41 changes: 15 additions & 26 deletions packages/astro/src/core/app/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ 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';
Expand Down Expand Up @@ -90,35 +89,25 @@ export class NodeApp extends App {
.map((e) => e.trim())?.[0];
};

// Get the used protocol between the end client and first proxy.
// NOTE: Some proxies append values with spaces and some do not.
// We need to handle it here and parse the header correctly.
// @example "https, http,http" => "http"
const forwardedProtocol = getFirstForwardedValue(req.headers['x-forwarded-proto']);
const providedProtocol = isEncrypted ? 'https' : 'http';
const protocol = forwardedProtocol ?? providedProtocol;

// @example "example.com,www2.example.com" => "example.com"
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;
// Validate forwarded headers
// NOTE: Header values may have commas/spaces from proxy chains, extract first value
const validated = App.validateForwardedHeaders(
getFirstForwardedValue(req.headers['x-forwarded-proto']),
getFirstForwardedValue(req.headers['x-forwarded-host']),
getFirstForwardedValue(req.headers['x-forwarded-port']),
allowedDomains,
);

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

let url: URL;
try {
Expand Down
Loading
Loading