sync: update from private repo (8d2961c)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-17 01:37:03 +00:00
parent a126fe8d18
commit 35e4fd30d2
3 changed files with 105 additions and 8 deletions

View File

@ -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}`),

View File

@ -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);
});
});

View File

@ -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();
});