Skip to content

Commit c13b536

Browse files
committed
Validate Host header against allowedDomains
- Add Host header validation against configured allowedDomains - Rename validate-forwarded-headers.ts to validate-headers.ts - Read error pages from disk first before falling back to experimentalErrorPageHost - Update test fixtures with appropriate allowedDomains configuration
1 parent 2bf965c commit c13b536

11 files changed

Lines changed: 511 additions & 123 deletions

File tree

.changeset/calm-birds-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/node': patch
3+
---
4+
5+
Improves error page loading to read from disk first before falling back to configured host

.changeset/strong-wolves-march.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 Host header handling for SSR deployments behind proxies

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js';
1010
import type { RenderOptions } from './index.js';
1111
import { App } from './index.js';
1212
import type { NodeAppHeadersJson, SerializedSSRManifest, SSRManifest } from './types.js';
13+
import { validateForwardedHeaders, validateHost } from './validate-headers.js';
1314

1415
export { apply as applyPolyfills } from '../polyfill.js';
1516

@@ -90,33 +91,38 @@ export class NodeApp extends App {
9091
};
9192

9293
const providedProtocol = isEncrypted ? 'https' : 'http';
93-
const providedHostname = req.headers.host ?? req.headers[':authority'];
94+
const untrustedHostname = req.headers.host ?? req.headers[':authority'];
9495

9596
// Validate forwarded headers
9697
// NOTE: Header values may have commas/spaces from proxy chains, extract first value
97-
const validated = App.validateForwardedHeaders(
98+
const validated = validateForwardedHeaders(
9899
getFirstForwardedValue(req.headers['x-forwarded-proto']),
99100
getFirstForwardedValue(req.headers['x-forwarded-host']),
100101
getFirstForwardedValue(req.headers['x-forwarded-port']),
101102
allowedDomains,
102103
);
103104

104105
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,
106+
// validated.host is already validated against allowedDomains
107+
// For the Host header, we also need to validate against allowedDomains to prevent SSRF
108+
// The Host header is only trusted if allowedDomains is configured AND the host matches
109+
// Otherwise, fall back to 'localhost' to prevent SSRF attacks
110+
const validatedHostname = validateHost(
111+
typeof untrustedHostname === 'string' ? untrustedHostname : undefined,
112+
protocol,
113+
allowedDomains,
108114
);
109-
const hostname = validated.host ?? sanitizedProvidedHostname;
115+
const hostname = validated.host ?? validatedHostname ?? 'localhost';
110116
const port = validated.port;
111117

112118
let url: URL;
113119
try {
114120
const hostnamePort = getHostnamePort(hostname, port);
115121
url = new URL(`${protocol}://${hostnamePort}${req.url}`);
116122
} catch {
117-
// Fallback to the provided hostname and port
118-
const hostnamePort = getHostnamePort(providedHostname, port);
119-
url = new URL(`${providedProtocol}://${hostnamePort}`);
123+
// Fallback using validated hostname to prevent SSRF
124+
const hostnamePort = getHostnamePort(hostname, port);
125+
url = new URL(`${protocol}://${hostnamePort}`);
120126
}
121127

122128
const options: RequestInit = {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { matchPattern, type RemotePattern } from '@astrojs/internal-helpers/remote';
2+
3+
/**
4+
* Sanitize a hostname by rejecting any with path separators.
5+
* Prevents path injection attacks. Invalid hostnames return undefined.
6+
*/
7+
function sanitizeHost(hostname: string | undefined): string | undefined {
8+
if (!hostname) return undefined;
9+
// Reject any hostname containing path separators - they're invalid
10+
if (/[/\\]/.test(hostname)) return undefined;
11+
return hostname;
12+
}
13+
14+
interface ParsedHost {
15+
hostname: string;
16+
port: string | undefined;
17+
}
18+
19+
/**
20+
* Parse a host string into hostname and port components.
21+
*/
22+
function parseHost(host: string): ParsedHost {
23+
const parts = host.split(':');
24+
return {
25+
hostname: parts[0],
26+
port: parts[1],
27+
};
28+
}
29+
30+
/**
31+
* Check if a host matches any of the allowed domain patterns.
32+
* Assumes hostname and port are already sanitized/parsed.
33+
*/
34+
function matchesAllowedDomains(
35+
hostname: string,
36+
protocol: string,
37+
port: string | undefined,
38+
allowedDomains: Partial<RemotePattern>[],
39+
): boolean {
40+
const hostWithPort = port ? `${hostname}:${port}` : hostname;
41+
const urlString = `${protocol}://${hostWithPort}`;
42+
43+
if (!URL.canParse(urlString)) {
44+
return false;
45+
}
46+
47+
const testUrl = new URL(urlString);
48+
return allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
49+
}
50+
51+
/**
52+
* Validate a host against allowedDomains.
53+
* Returns the host only if it matches an allowed pattern, otherwise undefined.
54+
* This prevents SSRF attacks by ensuring the Host header is trusted.
55+
*/
56+
export function validateHost(
57+
host: string | undefined,
58+
protocol: string,
59+
allowedDomains?: Partial<RemotePattern>[],
60+
): string | undefined {
61+
if (!host || host.length === 0) return undefined;
62+
if (!allowedDomains || allowedDomains.length === 0) return undefined;
63+
64+
const sanitized = sanitizeHost(host);
65+
if (!sanitized) return undefined;
66+
67+
const { hostname, port } = parseHost(sanitized);
68+
if (matchesAllowedDomains(hostname, protocol, port, allowedDomains)) {
69+
return sanitized;
70+
}
71+
72+
return undefined;
73+
}
74+
75+
/**
76+
* Validate forwarded headers (proto, host, port) against allowedDomains.
77+
* Returns validated values or undefined for rejected headers.
78+
* Uses strict defaults: http/https only for proto, rejects port if not in allowedDomains.
79+
*/
80+
export function validateForwardedHeaders(
81+
forwardedProtocol?: string,
82+
forwardedHost?: string,
83+
forwardedPort?: string,
84+
allowedDomains?: Partial<RemotePattern>[],
85+
): { protocol?: string; host?: string; port?: string } {
86+
const result: { protocol?: string; host?: string; port?: string } = {};
87+
88+
// Validate protocol
89+
if (forwardedProtocol) {
90+
if (allowedDomains && allowedDomains.length > 0) {
91+
const hasProtocolPatterns = allowedDomains.some((pattern) => pattern.protocol !== undefined);
92+
if (hasProtocolPatterns) {
93+
// Validate against allowedDomains patterns
94+
try {
95+
const testUrl = new URL(`${forwardedProtocol}://example.com`);
96+
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
97+
if (isAllowed) {
98+
result.protocol = forwardedProtocol;
99+
}
100+
} catch {
101+
// Invalid protocol, omit from result
102+
}
103+
} else if (/^https?$/.test(forwardedProtocol)) {
104+
// allowedDomains exist but no protocol patterns, allow http/https
105+
result.protocol = forwardedProtocol;
106+
}
107+
} else if (/^https?$/.test(forwardedProtocol)) {
108+
// No allowedDomains, only allow http/https
109+
result.protocol = forwardedProtocol;
110+
}
111+
}
112+
113+
// Validate port first
114+
if (forwardedPort && allowedDomains && allowedDomains.length > 0) {
115+
const hasPortPatterns = allowedDomains.some((pattern) => pattern.port !== undefined);
116+
if (hasPortPatterns) {
117+
// Validate against allowedDomains patterns
118+
const isAllowed = allowedDomains.some((pattern) => pattern.port === forwardedPort);
119+
if (isAllowed) {
120+
result.port = forwardedPort;
121+
}
122+
}
123+
// If no port patterns, reject the header (strict security default)
124+
}
125+
126+
// Validate host (extract port from hostname for validation)
127+
// Reject empty strings and sanitize to prevent path injection
128+
if (forwardedHost && forwardedHost.length > 0 && allowedDomains && allowedDomains.length > 0) {
129+
const protoForValidation = result.protocol || 'https';
130+
const sanitized = sanitizeHost(forwardedHost);
131+
if (sanitized) {
132+
const { hostname, port: portFromHost } = parseHost(sanitized);
133+
const portForValidation = result.port || portFromHost;
134+
if (matchesAllowedDomains(hostname, protoForValidation, portForValidation, allowedDomains)) {
135+
result.host = sanitized;
136+
}
137+
}
138+
}
139+
140+
return result;
141+
}

0 commit comments

Comments
 (0)