Skip to content

Commit 2066f39

Browse files
authored
Fix crash on malformed port in Host header (#17572)
1 parent 089b5ec commit 2066f39

5 files changed

Lines changed: 125 additions & 20 deletions

File tree

‎.changeset/orange-peas-dress.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+
Fixes a crash when a request arrives with a malformed port in the `Host` header (for example `example.com:65536` or `example.com:8080:8080`). Such a host made the constructed request URL invalid, and the fallback that was meant to recover reused the same invalid host and threw again. The request URL now degrades to a host the server controls when the incoming host cannot be parsed, so the request is handled instead of erroring.

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

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,7 @@ export function createRequestFromNodeRequest(
7575
? `localhost:${serverPort}`
7676
: 'localhost';
7777

78-
let url: URL;
79-
try {
80-
url = new URL(`${protocol}://${hostname}${req.url}`);
81-
} catch {
82-
url = new URL(`${protocol}://${hostname}`);
83-
}
78+
const url = buildRequestUrl(protocol, hostname, req.url, serverPort);
8479

8580
const options: RequestInit = {
8681
method: req.method || 'GET',
@@ -175,15 +170,7 @@ export function createRequest(
175170
validated.port ??
176171
(!validated.host && !validatedHostname && serverPort ? String(serverPort) : undefined);
177172

178-
let url: URL;
179-
try {
180-
const hostnamePort = getHostnamePort(hostname, port);
181-
url = new URL(`${protocol}://${hostnamePort}${req.url}`);
182-
} catch {
183-
// Fallback using validated hostname to prevent SSRF
184-
const hostnamePort = getHostnamePort(hostname, port);
185-
url = new URL(`${protocol}://${hostnamePort}`);
186-
}
173+
const url = buildRequestUrl(protocol, getHostnamePort(hostname, port), req.url, serverPort);
187174

188175
const options: RequestInit = {
189176
method: req.method || 'GET',
@@ -401,6 +388,33 @@ function getHostnamePort(hostname: string | string[] | undefined, port?: string)
401388
return hostnamePort;
402389
}
403390

391+
/**
392+
* Builds the request URL from a client-supplied host, which may contain an
393+
* unparseable port (e.g. `example.com:65536`). Parsing degrades in steps so
394+
* that construction always yields a URL:
395+
*
396+
* 1. Full URL including the request path.
397+
* 2. Origin only, dropping a request path that alone made the URL invalid.
398+
* 3. A host the server controls, when the host itself is unparseable — using
399+
* the listening port when known so the origin still carries the right port.
400+
*/
401+
function buildRequestUrl(
402+
protocol: string,
403+
hostnamePort: string,
404+
requestPath: string | undefined,
405+
serverPort?: number,
406+
): URL {
407+
const path = requestPath ?? '';
408+
if (URL.canParse(`${protocol}://${hostnamePort}${path}`)) {
409+
return new URL(`${protocol}://${hostnamePort}${path}`);
410+
}
411+
if (URL.canParse(`${protocol}://${hostnamePort}`)) {
412+
return new URL(`${protocol}://${hostnamePort}`);
413+
}
414+
const fallbackHost = serverPort ? `localhost:${serverPort}` : 'localhost';
415+
return new URL(`${protocol}://${fallbackHost}`);
416+
}
417+
404418
function makeRequestHeaders(req: NodeRequest): Headers {
405419
const headers = new Headers();
406420
for (const [name, value] of Object.entries(req.headers)) {

‎packages/astro/src/core/app/validate-headers.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ interface ParsedHost {
3131
}
3232

3333
/**
34-
* Parse a host string into hostname and port components.
34+
* Parse a host string into hostname and port components. Returns `undefined`
35+
* for a host that carries more than a single `hostname:port` pair (e.g.
36+
* `example.com:8080:8080`), which is not a valid host and would otherwise be
37+
* accepted by inspecting only the first two segments.
3538
*/
36-
function parseHost(host: string): ParsedHost {
39+
function parseHost(host: string): ParsedHost | undefined {
3740
const parts = host.split(':');
41+
if (parts.length > 2) return undefined;
3842
return {
3943
hostname: parts[0],
4044
port: parts[1],
@@ -78,7 +82,10 @@ export function validateHost(
7882
const sanitized = sanitizeHost(host);
7983
if (!sanitized) return undefined;
8084

81-
const { hostname, port } = parseHost(sanitized);
85+
const parsed = parseHost(sanitized);
86+
if (!parsed) return undefined;
87+
88+
const { hostname, port } = parsed;
8289
if (matchesAllowedDomains(hostname, protocol, port, allowedDomains)) {
8390
return sanitized;
8491
}
@@ -145,8 +152,9 @@ export function validateForwardedHeaders(
145152
if (forwardedHost && forwardedHost.length > 0 && allowedDomains && allowedDomains.length > 0) {
146153
const protoForValidation = result.protocol || 'https';
147154
const sanitized = sanitizeHost(forwardedHost);
148-
if (sanitized) {
149-
const { hostname, port: portFromHost } = parseHost(sanitized);
155+
const parsed = sanitized ? parseHost(sanitized) : undefined;
156+
if (sanitized && parsed) {
157+
const { hostname, port: portFromHost } = parsed;
150158
const portForValidation = result.port || portFromHost;
151159
if (matchesAllowedDomains(hostname, protoForValidation, portForValidation, allowedDomains)) {
152160
result.host = sanitized;

‎packages/astro/test/units/app/node.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,21 @@ describe('node', () => {
416416
assert.equal(result.url, 'https://example.com:3000/');
417417
});
418418

419+
it('rejects Host header with a duplicated port', () => {
420+
const result = createRequest(
421+
{
422+
...mockNodeRequest,
423+
headers: {
424+
host: 'example.com:8080:8080',
425+
},
426+
},
427+
{ allowedDomains: [{ hostname: 'example.com' }] },
428+
);
429+
// A host carrying two ports is invalid and must not validate,
430+
// so it falls back to localhost rather than being interpolated verbatim.
431+
assert.equal(result.url, 'https://localhost/');
432+
});
433+
419434
it('accepts Host header with wildcard pattern in allowedDomains', () => {
420435
const result = createRequest(
421436
{
@@ -921,6 +936,41 @@ describe('node', () => {
921936
assert.equal((result as any)[Symbol.for('astro.clientAddress')], '2.2.2.2');
922937
});
923938
});
939+
940+
describe('malformed host header', () => {
941+
// A Host header with an unparseable port makes the interpolated URL
942+
// invalid. The construction must not throw: it falls back to a host the
943+
// server controls so callers always receive a Request.
944+
const malformedHosts = [
945+
'example.com:65536',
946+
'example.com:99999',
947+
'example.com:abc',
948+
'example.com:443:443',
949+
'example.com:-1',
950+
];
951+
952+
for (const host of malformedHosts) {
953+
it(`does not throw for host "${host}"`, () => {
954+
const build = () =>
955+
createRequestFromNodeRequest({
956+
...mockNodeRequest,
957+
socket: { encrypted: false, remoteAddress: '2.2.2.2' },
958+
headers: { host },
959+
});
960+
assert.doesNotThrow(build);
961+
assert.ok(URL.canParse(build().url));
962+
});
963+
}
964+
965+
it('preserves a valid host with the maximum port', () => {
966+
const result = createRequestFromNodeRequest({
967+
...mockNodeRequest,
968+
socket: { encrypted: false, remoteAddress: '2.2.2.2' },
969+
headers: { host: 'example.com:65535' },
970+
});
971+
assert.equal(new URL(result.url).host, 'example.com:65535');
972+
});
973+
});
924974
});
925975

926976
describe('request body handling', () => {

‎packages/integrations/node/test/static-headers.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
import * as assert from 'node:assert/strict';
2+
import net from 'node:net';
23
import { after, before, describe, it } from 'node:test';
34
import nodejs from '../dist/index.js';
45
import { type Fixture, loadFixture, waitServerListen, type AdapterServer } from './test-utils.ts';
56

7+
/**
8+
* Sends a raw HTTP request with a hand-written Host header and resolves with
9+
* the status line. `fetch` rewrites the Host header, so a socket is the only
10+
* way to exercise a client-supplied host with a malformed port.
11+
*/
12+
function requestWithHost(host: string, port: number, hostHeader: string): Promise<string> {
13+
return new Promise((resolve, reject) => {
14+
const socket = net.connect(port, host, () => {
15+
socket.write(`GET / HTTP/1.1\r\nHost: ${hostHeader}\r\nConnection: close\r\n\r\n`);
16+
});
17+
let body = '';
18+
socket.setEncoding('utf8');
19+
socket.on('data', (chunk) => (body += chunk));
20+
socket.on('end', () => resolve(body.split('\r\n')[0] ?? ''));
21+
socket.on('error', reject);
22+
});
23+
}
24+
625
type StaticHeaderEntry = { pathname: string; headers: Array<{ key: string; value: string }> };
726

827
describe('Static headers', () => {
@@ -73,6 +92,15 @@ describe('Static headers', () => {
7392
'should contain script-src directive due to server island',
7493
);
7594
});
95+
96+
it('survives a request with a malformed port in the Host header', async () => {
97+
// A malformed port makes the URL unparseable while the static handler
98+
// builds a Request to look up per-route headers. The request must not
99+
// take the process down; a follow-up request must still be served.
100+
await requestWithHost(server.host ?? '127.0.0.1', server.port, 'example.com:65536');
101+
const res = await fetch(`http://${server.host}:${server.port}/`);
102+
assert.equal(res.status, 200);
103+
});
76104
});
77105

78106
describe('Static headers listener cleanup', () => {

0 commit comments

Comments
 (0)