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/orange-peas-dress.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 29 additions & 15 deletions packages/astro/src/core/app/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we check if this is still parsable?

}

function makeRequestHeaders(req: NodeRequest): Headers {
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
Expand Down
18 changes: 13 additions & 5 deletions packages/astro/src/core/app/validate-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
50 changes: 50 additions & 0 deletions packages/astro/test/units/app/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 28 additions & 0 deletions packages/integrations/node/test/static-headers.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading