diff --git a/src/bridge/server.ts b/src/bridge/server.ts index 148e2a1..f1507c9 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -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}`), diff --git a/src/net/http-redirect.test.ts b/src/net/http-redirect.test.ts index be5556a..a5e15d5 100644 --- a/src/net/http-redirect.test.ts +++ b/src/net/http-redirect.test.ts @@ -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((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((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((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((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); + }); }); diff --git a/src/net/http-redirect.ts b/src/net/http-redirect.ts index be45563..9bbc426 100644 --- a/src/net/http-redirect.ts +++ b/src/net/http-redirect.ts @@ -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(); });