sync: update from private repo (8d2961c)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
a126fe8d18
commit
35e4fd30d2
@ -1338,14 +1338,21 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
}
|
||||
});
|
||||
if (tls.httpRedirect) {
|
||||
if (tls.redirectHost == null && (host === '0.0.0.0' || host === '::')) {
|
||||
logger.warn(
|
||||
`[server] HTTP->HTTPS redirect host falls back to the wildcard bind address (${host}); browsers cannot follow it. ` +
|
||||
`Set server.tls.redirect_host to the externally reachable hostname.`,
|
||||
const isWildcardBind = host === '0.0.0.0' || host === '::';
|
||||
// With a wildcard bind and no explicit redirect_host there is no single
|
||||
// correct host to pin (the bind address itself, 0.0.0.0, is not browser-
|
||||
// reachable). Derive the redirect target from the client's own Host header
|
||||
// instead — a scheme upgrade to the host they already used. 'localhost' is
|
||||
// only the last-resort pin for a request with no usable Host header.
|
||||
const preferRequestHost = tls.redirectHost == null && isWildcardBind;
|
||||
if (preferRequestHost) {
|
||||
logger.info(
|
||||
`[server] HTTP->HTTPS redirect derives its host from the request Host header ` +
|
||||
`(wildcard bind ${host}, no server.tls.redirect_host set). Set redirect_host to pin it explicitly.`,
|
||||
);
|
||||
}
|
||||
const pinnedHost = tls.redirectHost ?? (isLoopbackBind ? 'localhost' : host);
|
||||
const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost });
|
||||
const pinnedHost = tls.redirectHost ?? (isWildcardBind || isLoopbackBind ? 'localhost' : host);
|
||||
const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost, preferRequestHost });
|
||||
redirector.on('error', (e) => logger.warn(`[server] HTTP redirect listener error: ${(e as Error).message}`));
|
||||
redirector.listen(tls.httpRedirectPort, host, () =>
|
||||
logger.info(`HTTP->HTTPS redirect listening on http://${host}:${tls.httpRedirectPort}`),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import * as http from 'http';
|
||||
import { buildRedirectLocation, createHttpRedirectServer } from './http-redirect.js';
|
||||
import { buildRedirectLocation, createHttpRedirectServer, safeHostFromHeader } from './http-redirect.js';
|
||||
|
||||
describe('buildRedirectLocation', () => {
|
||||
it('pins the host and preserves path + query', () => {
|
||||
@ -67,4 +67,51 @@ describe('createHttpRedirectServer', () => {
|
||||
await new Promise<void>((r) => srv.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it('preferRequestHost: scheme-upgrades to the request Host (port stripped, https port reapplied)', async () => {
|
||||
const srv = createHttpRedirectServer({ httpsPort: 443, pinnedHost: 'localhost', preferRequestHost: true });
|
||||
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', r));
|
||||
try {
|
||||
const addr = srv.address();
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
const loc = await new Promise<string>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ hostname: '127.0.0.1', port, path: '/tasks?id=5', method: 'GET', headers: { Host: '10.0.0.10:9080' } },
|
||||
(res) => resolve(res.headers['location'] ?? ''),
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
expect(loc).toBe('https://10.0.0.10/tasks?id=5');
|
||||
} finally {
|
||||
await new Promise<void>((r) => srv.close(() => r()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeHostFromHeader', () => {
|
||||
it('strips the port from a host:port header', () => {
|
||||
expect(safeHostFromHeader('app.lan:9876')).toBe('app.lan');
|
||||
expect(safeHostFromHeader('10.0.0.10:9080')).toBe('10.0.0.10');
|
||||
});
|
||||
|
||||
it('keeps a bare hostname', () => {
|
||||
expect(safeHostFromHeader('app.lan')).toBe('app.lan');
|
||||
});
|
||||
|
||||
it('keeps an IPv6 literal in brackets and drops its port', () => {
|
||||
expect(safeHostFromHeader('[::1]:8443')).toBe('[::1]');
|
||||
expect(safeHostFromHeader('[2001:db8::1]')).toBe('[2001:db8::1]');
|
||||
});
|
||||
|
||||
it('returns null for missing/empty headers', () => {
|
||||
expect(safeHostFromHeader(undefined)).toBeNull();
|
||||
expect(safeHostFromHeader('')).toBeNull();
|
||||
expect(safeHostFromHeader(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('never lets CR/LF through (no header injection)', () => {
|
||||
const out = safeHostFromHeader('evil.com\r\nSet-Cookie: x=1');
|
||||
expect(out == null || !/[\r\n]/.test(out)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,14 +7,57 @@ export function buildRedirectLocation(pinnedHost: string, httpsPort: number, req
|
||||
return `https://${hostPort}${safePath.startsWith('/') ? safePath : `/${safePath}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a safe host from a request `Host` header for a scheme-upgrade redirect:
|
||||
* strip whitespace/CRLF, drop the port, and accept only a plausible
|
||||
* hostname / IPv4 / [IPv6] token. Returns null if the header is missing or does
|
||||
* not look like a valid host (caller then falls back to the pinned host).
|
||||
*
|
||||
* Used only in `preferRequestHost` mode (see createHttpRedirectServer).
|
||||
*/
|
||||
export function safeHostFromHeader(hostHeader: string | undefined): string | null {
|
||||
if (!hostHeader) return null;
|
||||
const cleaned = hostHeader.replace(/[\r\n\t ]/g, '');
|
||||
if (!cleaned) return null;
|
||||
let host = cleaned;
|
||||
if (host.startsWith('[')) {
|
||||
// IPv6 literal: [addr] or [addr]:port — keep the brackets, drop any port.
|
||||
const end = host.indexOf(']');
|
||||
if (end === -1) return null;
|
||||
host = host.slice(0, end + 1);
|
||||
if (!/^\[[0-9A-Fa-f:]+\]$/.test(host)) return null;
|
||||
return host;
|
||||
}
|
||||
const colon = host.indexOf(':');
|
||||
if (colon !== -1) host = host.slice(0, colon);
|
||||
if (!host) return null;
|
||||
// Hostname or IPv4: letters, digits, dot, hyphen only.
|
||||
if (!/^[A-Za-z0-9.-]+$/.test(host)) return null;
|
||||
return host;
|
||||
}
|
||||
|
||||
export interface RedirectServerOpts {
|
||||
httpsPort: number;
|
||||
pinnedHost: string;
|
||||
/**
|
||||
* When true AND the request carries a usable Host header, redirect to that
|
||||
* host (a scheme upgrade to the same host the client already used) instead of
|
||||
* the pinned host. Enabled ONLY when no redirect_host is configured and the
|
||||
* bind host is a wildcard (0.0.0.0 / ::), where there is no single correct
|
||||
* host to pin. Defaults to false, preserving the strict pinned-host behavior
|
||||
* (the open-redirect guard) for every other deployment.
|
||||
*/
|
||||
preferRequestHost?: boolean;
|
||||
}
|
||||
|
||||
export function createHttpRedirectServer(opts: RedirectServerOpts): Server {
|
||||
return createServer((req, res) => {
|
||||
const location = buildRedirectLocation(opts.pinnedHost, opts.httpsPort, req.url ?? '/');
|
||||
let host = opts.pinnedHost;
|
||||
if (opts.preferRequestHost) {
|
||||
const fromHeader = safeHostFromHeader(req.headers.host);
|
||||
if (fromHeader) host = fromHeader;
|
||||
}
|
||||
const location = buildRedirectLocation(host, opts.httpsPort, req.url ?? '/');
|
||||
res.writeHead(301, { Location: location });
|
||||
res.end();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user