Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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.
Comment thread
matthewp marked this conversation as resolved.
Outdated
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