diff --git a/packages/cli/src/push/__tests__/index.test.ts b/packages/cli/src/push/__tests__/index.test.ts index 220d9a0428..ca760ad3da 100644 --- a/packages/cli/src/push/__tests__/index.test.ts +++ b/packages/cli/src/push/__tests__/index.test.ts @@ -120,12 +120,16 @@ describe('Push', () => { }); describe('handleRequest', () => { - const req = mock({ user }); - const res = mock(); - const ws = mock(); const backendNames = ['sse', 'websocket'] as const; + let req: ReturnType>; + let res: ReturnType>; + let ws: ReturnType>; beforeEach(() => { + req = mock({ user }); + res = mock(); + ws = mock(); + res.status.mockReturnThis(); req.headers.host = host; @@ -162,12 +166,12 @@ describe('Push', () => { { name: 'origin does not match x-forwarded-host', origin: `https://${host}`, // this is correct - xForwardedHost: 'https://123example.com', // this is not + xForwardedHost: '123example.com', // this is not }, { name: 'origin does not match x-forwarded-host (subdomain)', origin: `https://${host}`, // this is correct - xForwardedHost: `https://subdomain.${host}`, // this is not + xForwardedHost: `subdomain.${host}`, // this is not }, ])('$name', ({ origin, xForwardedHost }) => { req.headers.origin = origin; @@ -225,6 +229,64 @@ describe('Push', () => { }); }); + describe('port normalization bug reproduction', () => { + test.each([ + { + name: 'HTTPS origin without port should match x-forwarded-host with default HTTPS port (443)', + origin: `https://${host}`, + xForwardedHost: `${host}:443`, + shouldPass: true, + }, + { + name: 'HTTP origin without port should match x-forwarded-host with default HTTP port (80)', + origin: `http://${host}`, + xForwardedHost: `${host}:80`, + shouldPass: true, + }, + { + name: 'origin with explicit port should match x-forwarded-host with same port', + origin: `https://${host}:8080`, + xForwardedHost: `${host}:8080`, + shouldPass: true, + }, + { + name: 'origin without port should NOT match x-forwarded-host with non-default port', + origin: `https://${host}`, + xForwardedHost: `${host}:8080`, + shouldPass: false, + }, + ])('$name', ({ origin, xForwardedHost, shouldPass }) => { + // ARRANGE + req.headers.origin = origin; + req.headers['x-forwarded-host'] = xForwardedHost; + + if (shouldPass) { + // Expected behavior: connection should be established + const emitSpy = jest.spyOn(push, 'emit'); + const connection = backendName === 'sse' ? { req, res } : ws; + + // ACT + push.handleRequest(req, res); + + // ASSERT + expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection); + expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef); + } else { + // Expected behavior: connection should be rejected + if (backendName === 'sse') { + expect(() => push.handleRequest(req, res)).toThrow( + new BadRequestError('Invalid origin!'), + ); + } else { + push.handleRequest(req, res); + expect(ws.send).toHaveBeenCalledWith('Invalid origin!'); + expect(ws.close).toHaveBeenCalledWith(1008); + } + expect(backend.add).not.toHaveBeenCalled(); + } + }); + }); + test('should throw if pushRef is invalid', () => { req.query = { pushRef: '' }; @@ -261,6 +323,61 @@ describe('Push', () => { expect(backend.add).not.toHaveBeenCalled(); }); } + + describe('additional edge cases', () => { + test('should handle array x-forwarded-host header (use first)', () => { + req.headers['x-forwarded-host'] = [host, 'other-host.com'] as any; + req.headers.origin = `https://${host}`; + + const emitSpy = jest.spyOn(push, 'emit'); + const connection = backendName === 'sse' ? { req, res } : ws; + + push.handleRequest(req, res); + + expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection); + expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef); + }); + + test('should handle Forwarded header with precedence over x-forwarded-*', () => { + req.headers.forwarded = `proto=https;host=${host}`; + req.headers['x-forwarded-host'] = 'wrong-host.com'; // Should be ignored + req.headers.origin = `https://${host}`; + + const emitSpy = jest.spyOn(push, 'emit'); + const connection = backendName === 'sse' ? { req, res } : ws; + + push.handleRequest(req, res); + + expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection); + expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef); + }); + + test('should normalize default ports in Forwarded header', () => { + req.headers.forwarded = `proto=https;host=${host}:443`; + req.headers.origin = `https://${host}`; + + const emitSpy = jest.spyOn(push, 'emit'); + const connection = backendName === 'sse' ? { req, res } : ws; + + push.handleRequest(req, res); + + expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection); + expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef); + }); + + test('should handle IPv6 addresses correctly', () => { + req.headers.origin = 'https://[::1]:443'; + req.headers['x-forwarded-host'] = '[::1]:443'; + + const emitSpy = jest.spyOn(push, 'emit'); + const connection = backendName === 'sse' ? { req, res } : ws; + + push.handleRequest(req, res); + + expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection); + expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef); + }); + }); }); }); }); diff --git a/packages/cli/src/push/__tests__/origin-validator.test.ts b/packages/cli/src/push/__tests__/origin-validator.test.ts new file mode 100644 index 0000000000..1407993843 --- /dev/null +++ b/packages/cli/src/push/__tests__/origin-validator.test.ts @@ -0,0 +1,435 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { validateOriginHeaders } from '../origin-validator'; + +describe('validateOriginHeaders', () => { + const host = 'example.com'; + + describe('basic validation', () => { + test('should return invalid for missing origin', () => { + const result = validateOriginHeaders({}); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Origin header is missing or malformed'); + }); + + test('should return invalid for empty origin', () => { + const result = validateOriginHeaders({ origin: '' }); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Origin header is missing or malformed'); + }); + + test('should return invalid for malformed origin', () => { + const result = validateOriginHeaders({ origin: 'invalid-origin' }); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Origin header is missing or malformed'); + }); + + test('should validate matching hosts with host header', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + host, + }); + expect(result.isValid).toBe(true); + expect(result.originInfo).toEqual({ protocol: 'https', host }); + expect(result.expectedHost).toBe(host); + expect(result.expectedProtocol).toBe('https'); + }); + + test('should validate matching hosts with x-forwarded-host header', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': host, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should reject mismatched hosts', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': 'wrong-host.com', + }); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Origin header does not match expected host'); + }); + }); + + describe('host normalization behavior', () => { + test('should normalize HTTPS default ports (443)', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': `${host}:443`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should normalize HTTP default ports (80)', () => { + const result = validateOriginHeaders({ + origin: `http://${host}`, + 'x-forwarded-host': `${host}:80`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should preserve non-default ports', () => { + const result = validateOriginHeaders({ + origin: `https://${host}:8080`, + 'x-forwarded-host': `${host}:8080`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(`${host}:8080`); + }); + + test('should reject mismatched ports', () => { + const result = validateOriginHeaders({ + origin: `https://${host}:8080`, + 'x-forwarded-host': `${host}:9000`, + }); + expect(result.isValid).toBe(false); + }); + + test('should handle IPv6 addresses correctly', () => { + const result = validateOriginHeaders({ + origin: 'https://[::1]:443', + 'x-forwarded-host': '[::1]:443', + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe('::1'); + }); + + test('should handle IPv6 with non-default ports', () => { + const result = validateOriginHeaders({ + origin: 'http://[2001:db8::1]:8080', + 'x-forwarded-host': '[2001:db8::1]:8080', + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe('2001:db8::1:8080'); + }); + + test('should handle complex domain names', () => { + const complexHost = 'sub.domain.example-site.com'; + const result = validateOriginHeaders({ + origin: `https://${complexHost}:443`, + 'x-forwarded-host': `${complexHost}:443`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(complexHost); + }); + + test('should handle invalid URL formats gracefully', () => { + const result = validateOriginHeaders({ + origin: 'https://example.com', + 'x-forwarded-host': 'invalid[host', + }); + expect(result.isValid).toBe(false); + expect(result.expectedHost).toBe('invalid[host'); + }); + }); + + describe('origin parsing behavior', () => { + test('should reject non-HTTP/HTTPS protocols', () => { + const result = validateOriginHeaders({ + origin: 'ftp://example.com', + host: 'example.com', + }); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Origin header is missing or malformed'); + }); + + test('should reject websocket protocols', () => { + const result = validateOriginHeaders({ + origin: 'ws://example.com', + host: 'example.com', + }); + expect(result.isValid).toBe(false); + }); + + test('should reject chrome-extension protocols', () => { + const result = validateOriginHeaders({ + origin: 'chrome-extension://example.com', + host: 'example.com', + }); + expect(result.isValid).toBe(false); + }); + + test('should handle mixed case protocols', () => { + const result = validateOriginHeaders({ + origin: `HTTPS://${host}`, + host, + }); + expect(result.isValid).toBe(true); + expect(result.originInfo?.protocol).toBe('https'); + }); + + test('should parse HTTP origins correctly', () => { + const result = validateOriginHeaders({ + origin: `http://${host}`, + host, + }); + expect(result.isValid).toBe(true); + expect(result.originInfo).toEqual({ + protocol: 'http', + host, + }); + }); + + test('should parse HTTPS origins correctly', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + host, + }); + expect(result.isValid).toBe(true); + expect(result.originInfo).toEqual({ + protocol: 'https', + host, + }); + }); + + test('should reject malformed URLs that throw during parsing', () => { + const result = validateOriginHeaders({ + origin: '://malformed', + host: 'example.com', + }); + expect(result.isValid).toBe(false); + }); + }); + + describe('header precedence and processing', () => { + test('should handle Forwarded header with precedence over x-forwarded-*', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: `proto=https;host=${host}`, + 'x-forwarded-host': 'wrong-host.com', // Should be ignored + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should parse RFC 7239 Forwarded header with quoted values', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: `proto="https";host="${host}"`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should parse Forwarded header with single quotes', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: `proto='https';host='${host}'`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should handle multiple Forwarded entries (use first)', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: `proto=https;host=${host}, proto=http;host=other.com`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should handle Forwarded header with spaces around values', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: ` proto = https ; host = ${host} `, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should ignore unknown parameters in Forwarded header', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: `for=192.0.2.60;proto=https;host=${host};by=proxy.com`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should fallback to X-Forwarded-* when Forwarded header incomplete', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: 'for=192.0.2.60', // Missing host and proto + 'x-forwarded-host': host, + 'x-forwarded-proto': 'https', + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should handle array x-forwarded-host headers (use first)', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': [host, 'other-host.com'], + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should handle array x-forwarded-proto headers (use first)', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': host, + 'x-forwarded-proto': ['https', 'http'], + }); + expect(result.isValid).toBe(true); + expect(result.expectedProtocol).toBe('https'); + }); + + test('should handle comma-separated x-forwarded-proto (use first)', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': host, + 'x-forwarded-proto': 'https, http', + }); + expect(result.isValid).toBe(true); + expect(result.expectedProtocol).toBe('https'); + }); + + test('should fallback to Host header when no proxy headers exist', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + host, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + }); + + describe('protocol validation behavior', () => { + test('should handle valid protocols in Forwarded header', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: `proto=https;host=${host}`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedProtocol).toBe('https'); + }); + + test('should ignore invalid protocols in Forwarded header', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: `proto=ftp;host=${host}`, // Invalid protocol should be ignored + }); + expect(result.isValid).toBe(true); + expect(result.expectedProtocol).toBe('https'); // Falls back to origin protocol + }); + + test('should ignore invalid protocols in X-Forwarded-Proto header', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': host, + 'x-forwarded-proto': 'websocket', // Invalid protocol + }); + expect(result.isValid).toBe(true); + expect(result.expectedProtocol).toBe('https'); // Falls back to origin protocol + }); + + test('should handle comma-separated X-Forwarded-Proto with invalid first value', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': host, + 'x-forwarded-proto': 'ftp,https', // Invalid first, valid second + }); + expect(result.isValid).toBe(true); + expect(result.expectedProtocol).toBe('https'); // Falls back to origin protocol + }); + + test('should handle protocol change via Forwarded header', () => { + const result = validateOriginHeaders({ + origin: `http://${host}`, + forwarded: `proto=http;host=${host}:80`, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + expect(result.expectedProtocol).toBe('http'); + }); + }); + + describe('edge cases and error handling', () => { + test('should handle missing host header when no proxy headers exist', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + // No host, x-forwarded-host, or forwarded headers + }); + expect(result.isValid).toBe(false); + expect(result.expectedHost).toBe(''); + }); + + test('should handle empty forwarded header gracefully', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: '', + 'x-forwarded-host': host, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should handle invalid forwarded header format', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + forwarded: 'invalid-format', + 'x-forwarded-host': host, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); + }); + + test('should handle empty x-forwarded-host array', () => { + const result = validateOriginHeaders({ + origin: `https://${host}`, + 'x-forwarded-host': [], + host, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(host); // Falls back to host header + }); + + test('should handle numeric IP addresses correctly', () => { + const ipHost = '192.168.1.100:3000'; + const result = validateOriginHeaders({ + origin: `https://${ipHost}`, + 'x-forwarded-host': ipHost, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(ipHost); + }); + + test('should handle localhost correctly', () => { + const localhostHost = 'localhost:8080'; + const result = validateOriginHeaders({ + origin: `http://${localhostHost}`, + 'x-forwarded-host': localhostHost, + }); + expect(result.isValid).toBe(true); + expect(result.expectedHost).toBe(localhostHost); + }); + + test('should allow origins with query parameters when host matches', () => { + const queryOrigin = `https://${host}?query=param#fragment`; + const result = validateOriginHeaders({ + origin: queryOrigin, + host, + }); + expect(result.isValid).toBe(true); + expect(result.originInfo?.host).toBe(host); + }); + + test('should allow origins with path components when host matches', () => { + const pathOrigin = `https://${host}/path/to/resource`; + const result = validateOriginHeaders({ + origin: pathOrigin, + host, + }); + expect(result.isValid).toBe(true); + expect(result.originInfo?.host).toBe(host); + }); + }); +}); diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index 743f809b56..e649783149 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -18,6 +18,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { TypedEmitter } from '@/typed-emitter'; +import { validateOriginHeaders } from './origin-validator'; import { PushConfig } from './push.config'; import { SSEPush } from './sse.push'; import type { OnPushMessage, PushResponse, SSEPushRequest, WebSocketPushRequest } from './types'; @@ -111,24 +112,25 @@ export class Push extends TypedEmitter { let connectionError = ''; - // Extract host domain from origin - const originHost = headers.origin?.replace(/^https?:\/\//, ''); - if (!pushRef) { connectionError = 'The query parameter "pushRef" is missing!'; - } else if (!originHost) { - this.logger.warn('Origin header is missing'); - - connectionError = 'Invalid origin!'; } else if (inProduction) { - const expectedHost = - typeof headers['x-forwarded-host'] === 'string' - ? headers['x-forwarded-host'] - : headers.host; - if (expectedHost !== originHost) { + const validation = validateOriginHeaders(headers); + if (!validation.isValid) { this.logger.warn( - `Origin header does NOT match the expected origin. (Origin: "${originHost}", Expected: "${expectedHost}")`, - { headers: pick(headers, ['host', 'origin', 'x-forwarded-proto', 'x-forwarded-host']) }, + 'Origin header does NOT match the expected origin. ' + + `(Origin: "${headers.origin}" -> "${validation.originInfo?.host || 'N/A'}", ` + + `Expected: "${validation.rawExpectedHost}" -> "${validation.expectedHost}", ` + + `Protocol: "${validation.expectedProtocol}")`, + { + headers: pick(headers, [ + 'host', + 'origin', + 'x-forwarded-proto', + 'x-forwarded-host', + 'forwarded', + ]), + }, ); connectionError = 'Invalid origin!'; } diff --git a/packages/cli/src/push/origin-validator.ts b/packages/cli/src/push/origin-validator.ts new file mode 100644 index 0000000000..95f0b241f9 --- /dev/null +++ b/packages/cli/src/push/origin-validator.ts @@ -0,0 +1,236 @@ +import type { Request } from 'express'; + +export interface OriginValidationResult { + isValid: boolean; + originInfo?: { protocol: 'http' | 'https'; host: string }; + expectedHost?: string; + expectedProtocol?: 'http' | 'https'; + rawExpectedHost?: string; + error?: string; +} + +/** + * Validates origin headers against expected host from proxy headers. + * Handles X-Forwarded-Host, X-Forwarded-Proto, and RFC 7239 Forwarded headers. + * + * @param headers HTTP request headers + * @returns Validation result with details about the origin check + */ +export function validateOriginHeaders(headers: Request['headers']): OriginValidationResult { + // Parse and normalize the origin using native URL class + const originInfo = parseOrigin(headers.origin ?? ''); + + if (!originInfo) { + return { + isValid: false, + error: 'Origin header is missing or malformed', + }; + } + + // Extract expected host from proxy headers using same precedence logic + let rawExpectedHost: string | undefined; + let expectedProtocol = originInfo.protocol; + + // 1. Try RFC 7239 Forwarded header first + const forwarded = parseForwardedHeader(headers.forwarded ?? ''); + if (forwarded?.host) { + rawExpectedHost = forwarded.host; + if (forwarded.proto) { + const validatedProto = validateProtocol(forwarded.proto); + if (validatedProto) { + expectedProtocol = validatedProto; + } + } + } + // 2. Try X-Forwarded-Host + else { + const xForwardedHost = getFirstHeaderValue(headers['x-forwarded-host']); + if (xForwardedHost) { + rawExpectedHost = xForwardedHost; + const xForwardedProto = getFirstHeaderValue(headers['x-forwarded-proto']); + if (xForwardedProto) { + const validatedProto = validateProtocol(xForwardedProto.split(',')[0]?.trim()); + if (validatedProto) { + expectedProtocol = validatedProto; + } + } + } + // 3. Fallback to Host header + else { + rawExpectedHost = headers.host; + } + } + + // Normalize expected host using the determined protocol + const normalizedExpectedHost = normalizeHost(rawExpectedHost ?? '', expectedProtocol); + + const isValid = normalizedExpectedHost === originInfo.host; + + return { + isValid, + originInfo, + expectedHost: normalizedExpectedHost, + expectedProtocol, + rawExpectedHost, + error: isValid ? undefined : 'Origin header does not match expected host', + }; +} + +/** + * Normalizes a host by removing default ports for the given protocol. + * Uses native URL class for robust parsing of hosts and ports. + * + * @param host The host string (e.g., "example.com:80", "example.com", "[::1]:8080") + * @param protocol The protocol ('http' or 'https') + * @returns The normalized host string with default ports removed + */ +function normalizeHost(host: string, protocol: 'http' | 'https'): string { + if (!host) return host; + + try { + // Use URL constructor to parse the host properly + // We need to prepend a protocol to make it a valid URL + const url = new URL(`${protocol}://${host}`); + + // URL.host includes port, URL.hostname excludes port + // If the port is default for the protocol, URL.host will exclude it + // Otherwise, it will include it + const defaultPort = protocol === 'https' ? '443' : '80'; + const actualPort = url.port || defaultPort; + + // If the port matches the default, return hostname only (strip IPv6 brackets) + if (actualPort === defaultPort) { + return stripIPv6Brackets(url.hostname); + } + + // Return hostname:port for non-default ports (strip IPv6 brackets) + return stripIPv6Brackets(url.host); + } catch { + // If URL parsing fails, fall back to original host + return host; + } +} + +/** + * Strips brackets from IPv6 addresses for consistency. + * IPv6 brackets are URL syntax and should be removed when comparing hosts. + * + * @param hostname The hostname that may contain IPv6 brackets (e.g., "[::1]" or "[::1]:8080") + * @returns Hostname with IPv6 brackets removed if present (e.g., "::1" or "::1:8080") + */ +function stripIPv6Brackets(hostname: string): string { + // Handle IPv6 with port: [::1]:8080 -> ::1:8080 + if (hostname.startsWith('[') && hostname.includes(']:')) { + const closingBracket = hostname.indexOf(']:'); + const ipv6 = hostname.slice(1, closingBracket); // Extract ::1 + const port = hostname.slice(closingBracket + 2); // Extract 8080 + return `${ipv6}:${port}`; + } + // Handle IPv6 without port: [::1] -> ::1 + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return hostname.slice(1, -1); + } + return hostname; +} + +/** + * Safely extracts the first value from a header that could be string or string[]. + * + * @param header The header value (string or string[]) + * @returns First header value or undefined + */ +function getFirstHeaderValue(header: string | string[] | undefined): string | undefined { + if (!header) return undefined; + if (typeof header === 'string') return header; + return header[0]; // Take first value from array +} + +/** + * Validates and normalizes a protocol value to ensure it's http or https. + * + * @param proto Protocol value from headers + * @returns 'http' or 'https' if valid, undefined if invalid + */ +function validateProtocol(proto: string | undefined): 'http' | 'https' | undefined { + if (!proto) return undefined; + const normalized = proto.toLowerCase().trim(); + return normalized === 'http' || normalized === 'https' ? normalized : undefined; +} + +/** + * Parses the RFC 7239 Forwarded header to extract host and proto values. + * + * @param forwardedHeader The Forwarded header value (e.g., "for=192.0.2.60;proto=http;host=example.com") + * @returns Object with host and proto, or null if parsing fails + */ +function parseForwardedHeader(forwardedHeader: string): { host?: string; proto?: string } | null { + if (!forwardedHeader || typeof forwardedHeader !== 'string') { + return null; + } + + try { + // Parse the first forwarded entry (comma-separated list) + const firstEntry = forwardedHeader.split(',')[0]?.trim(); + if (!firstEntry) return null; + + const result: { host?: string; proto?: string } = {}; + + // Parse semicolon-separated key=value pairs + const pairs = firstEntry.split(';'); + for (const pair of pairs) { + const [key, value] = pair.split('=', 2); + if (!key || !value) continue; + + const cleanKey = key.trim().toLowerCase(); + const cleanValue = value.trim().replace(/^["']|["']$/g, ''); // Remove quotes + + if (cleanKey === 'host') { + result.host = cleanValue; + } else if (cleanKey === 'proto') { + result.proto = cleanValue; + } + } + + return result; + } catch { + return null; + } +} + +/** + * Extracts protocol and normalized host from an origin URL using native URL class. + * + * @param origin The origin URL (e.g., "https://example.com", "http://localhost:3000") + * @returns Object with protocol and normalized host, or null if invalid + */ +function parseOrigin(origin: string): { protocol: 'http' | 'https'; host: string } | null { + if (!origin || typeof origin !== 'string') { + return null; + } + + try { + const url = new URL(origin); + const protocol = url.protocol.toLowerCase(); + + if (protocol !== 'http:' && protocol !== 'https:') { + return null; + } + + const protocolName = protocol === 'https:' ? 'https' : 'http'; + + // Use the same normalization logic - remove default ports and IPv6 brackets + const defaultPort = protocolName === 'https' ? '443' : '80'; + const actualPort = url.port || defaultPort; + + const rawHost = actualPort === defaultPort ? url.hostname : url.host; + const normalizedHost = stripIPv6Brackets(rawHost); + + return { + protocol: protocolName, + host: normalizedHost, + }; + } catch { + // Invalid URL format + return null; + } +}