From ffa6cf6a644301bf1c2d9876d8689fc8998f290a Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Sun, 2 Aug 2026 12:03:27 -0400 Subject: [PATCH] Fix crash on malformed port in Host header --- .changeset/orange-peas-dress.md | 5 ++ packages/astro/src/core/app/node.ts | 44 ++++++++++------ .../astro/src/core/app/validate-headers.ts | 18 +++++-- packages/astro/test/units/app/node.test.ts | 50 +++++++++++++++++++ .../node/test/static-headers.test.ts | 28 +++++++++++ 5 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 .changeset/orange-peas-dress.md diff --git a/.changeset/orange-peas-dress.md b/.changeset/orange-peas-dress.md new file mode 100644 index 000000000000..112ccb8eded7 --- /dev/null +++ b/.changeset/orange-peas-dress.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +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. diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index f57e6f50ce7d..cc6a9d031605 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -75,12 +75,7 @@ export function createRequestFromNodeRequest( ? `localhost:${serverPort}` : 'localhost'; - let url: URL; - try { - url = new URL(`${protocol}://${hostname}${req.url}`); - } catch { - url = new URL(`${protocol}://${hostname}`); - } + const url = buildRequestUrl(protocol, hostname, req.url, serverPort); const options: RequestInit = { method: req.method || 'GET', @@ -175,15 +170,7 @@ export function createRequest( validated.port ?? (!validated.host && !validatedHostname && serverPort ? String(serverPort) : undefined); - let url: URL; - try { - const hostnamePort = getHostnamePort(hostname, port); - url = new URL(`${protocol}://${hostnamePort}${req.url}`); - } catch { - // Fallback using validated hostname to prevent SSRF - const hostnamePort = getHostnamePort(hostname, port); - url = new URL(`${protocol}://${hostnamePort}`); - } + const url = buildRequestUrl(protocol, getHostnamePort(hostname, port), req.url, serverPort); const options: RequestInit = { method: req.method || 'GET', @@ -401,6 +388,33 @@ function getHostnamePort(hostname: string | string[] | undefined, port?: string) return hostnamePort; } +/** + * Builds the request URL from a client-supplied host, which may contain an + * unparseable port (e.g. `example.com:65536`). Parsing degrades in steps so + * that construction always yields a URL: + * + * 1. Full URL including the request path. + * 2. Origin only, dropping a request path that alone made the URL invalid. + * 3. A host the server controls, when the host itself is unparseable — using + * the listening port when known so the origin still carries the right port. + */ +function buildRequestUrl( + protocol: string, + hostnamePort: string, + requestPath: string | undefined, + serverPort?: number, +): URL { + const path = requestPath ?? ''; + if (URL.canParse(`${protocol}://${hostnamePort}${path}`)) { + return new URL(`${protocol}://${hostnamePort}${path}`); + } + if (URL.canParse(`${protocol}://${hostnamePort}`)) { + return new URL(`${protocol}://${hostnamePort}`); + } + const fallbackHost = serverPort ? `localhost:${serverPort}` : 'localhost'; + return new URL(`${protocol}://${fallbackHost}`); +} + function makeRequestHeaders(req: NodeRequest): Headers { const headers = new Headers(); for (const [name, value] of Object.entries(req.headers)) { diff --git a/packages/astro/src/core/app/validate-headers.ts b/packages/astro/src/core/app/validate-headers.ts index a65552e04fd2..ed0c73e61b1c 100644 --- a/packages/astro/src/core/app/validate-headers.ts +++ b/packages/astro/src/core/app/validate-headers.ts @@ -31,10 +31,14 @@ interface ParsedHost { } /** - * Parse a host string into hostname and port components. + * Parse a host string into hostname and port components. Returns `undefined` + * for a host that carries more than a single `hostname:port` pair (e.g. + * `example.com:8080:8080`), which is not a valid host and would otherwise be + * accepted by inspecting only the first two segments. */ -function parseHost(host: string): ParsedHost { +function parseHost(host: string): ParsedHost | undefined { const parts = host.split(':'); + if (parts.length > 2) return undefined; return { hostname: parts[0], port: parts[1], @@ -78,7 +82,10 @@ export function validateHost( const sanitized = sanitizeHost(host); if (!sanitized) return undefined; - const { hostname, port } = parseHost(sanitized); + const parsed = parseHost(sanitized); + if (!parsed) return undefined; + + const { hostname, port } = parsed; if (matchesAllowedDomains(hostname, protocol, port, allowedDomains)) { return sanitized; } @@ -145,8 +152,9 @@ export function validateForwardedHeaders( if (forwardedHost && forwardedHost.length > 0 && allowedDomains && allowedDomains.length > 0) { const protoForValidation = result.protocol || 'https'; const sanitized = sanitizeHost(forwardedHost); - if (sanitized) { - const { hostname, port: portFromHost } = parseHost(sanitized); + const parsed = sanitized ? parseHost(sanitized) : undefined; + if (sanitized && parsed) { + const { hostname, port: portFromHost } = parsed; const portForValidation = result.port || portFromHost; if (matchesAllowedDomains(hostname, protoForValidation, portForValidation, allowedDomains)) { result.host = sanitized; diff --git a/packages/astro/test/units/app/node.test.ts b/packages/astro/test/units/app/node.test.ts index 72b50a58677d..8e7847dc27f2 100644 --- a/packages/astro/test/units/app/node.test.ts +++ b/packages/astro/test/units/app/node.test.ts @@ -416,6 +416,21 @@ describe('node', () => { assert.equal(result.url, 'https://example.com:3000/'); }); + it('rejects Host header with a duplicated port', () => { + const result = createRequest( + { + ...mockNodeRequest, + headers: { + host: 'example.com:8080:8080', + }, + }, + { allowedDomains: [{ hostname: 'example.com' }] }, + ); + // A host carrying two ports is invalid and must not validate, + // so it falls back to localhost rather than being interpolated verbatim. + assert.equal(result.url, 'https://localhost/'); + }); + it('accepts Host header with wildcard pattern in allowedDomains', () => { const result = createRequest( { @@ -921,6 +936,41 @@ describe('node', () => { assert.equal((result as any)[Symbol.for('astro.clientAddress')], '2.2.2.2'); }); }); + + describe('malformed host header', () => { + // A Host header with an unparseable port makes the interpolated URL + // invalid. The construction must not throw: it falls back to a host the + // server controls so callers always receive a Request. + const malformedHosts = [ + 'example.com:65536', + 'example.com:99999', + 'example.com:abc', + 'example.com:443:443', + 'example.com:-1', + ]; + + for (const host of malformedHosts) { + it(`does not throw for host "${host}"`, () => { + const build = () => + createRequestFromNodeRequest({ + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { host }, + }); + assert.doesNotThrow(build); + assert.ok(URL.canParse(build().url)); + }); + } + + it('preserves a valid host with the maximum port', () => { + const result = createRequestFromNodeRequest({ + ...mockNodeRequest, + socket: { encrypted: false, remoteAddress: '2.2.2.2' }, + headers: { host: 'example.com:65535' }, + }); + assert.equal(new URL(result.url).host, 'example.com:65535'); + }); + }); }); describe('request body handling', () => { diff --git a/packages/integrations/node/test/static-headers.test.ts b/packages/integrations/node/test/static-headers.test.ts index 68bfb82b089b..19aa1f1dee03 100644 --- a/packages/integrations/node/test/static-headers.test.ts +++ b/packages/integrations/node/test/static-headers.test.ts @@ -1,8 +1,27 @@ import * as assert from 'node:assert/strict'; +import net from 'node:net'; import { after, before, describe, it } from 'node:test'; import nodejs from '../dist/index.js'; import { type Fixture, loadFixture, waitServerListen, type AdapterServer } from './test-utils.ts'; +/** + * Sends a raw HTTP request with a hand-written Host header and resolves with + * the status line. `fetch` rewrites the Host header, so a socket is the only + * way to exercise a client-supplied host with a malformed port. + */ +function requestWithHost(host: string, port: number, hostHeader: string): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(port, host, () => { + socket.write(`GET / HTTP/1.1\r\nHost: ${hostHeader}\r\nConnection: close\r\n\r\n`); + }); + let body = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk) => (body += chunk)); + socket.on('end', () => resolve(body.split('\r\n')[0] ?? '')); + socket.on('error', reject); + }); +} + type StaticHeaderEntry = { pathname: string; headers: Array<{ key: string; value: string }> }; describe('Static headers', () => { @@ -73,6 +92,15 @@ describe('Static headers', () => { 'should contain script-src directive due to server island', ); }); + + it('survives a request with a malformed port in the Host header', async () => { + // A malformed port makes the URL unparseable while the static handler + // builds a Request to look up per-route headers. The request must not + // take the process down; a follow-up request must still be served. + await requestWithHost(server.host ?? '127.0.0.1', server.port, 'example.com:65536'); + const res = await fetch(`http://${server.host}:${server.port}/`); + assert.equal(res.status, 200); + }); }); describe('Static headers with non-root base', () => {