diff --git a/frontend/src/lib/xray/headers.ts b/frontend/src/lib/xray/headers.ts index 32694986..8368f92c 100644 --- a/frontend/src/lib/xray/headers.ts +++ b/frontend/src/lib/xray/headers.ts @@ -32,6 +32,27 @@ export function toHeaders(v2Headers: unknown): HeaderEntry[] { return out; } +// Case-insensitive lookup against a wire-shape header map. The legacy +// `Inbound.getHeader(obj, name)` iterated `obj.headers` as a HeaderEntry[]; +// this version reads the Record map our Zod schemas store. For repeated +// header names (string[] in TCP/WS-style maps) the first value wins — +// matches the legacy iteration order. Returns '' when missing, mirroring +// the legacy fallback so link-generator call sites stay simple. +export function getHeaderValue( + headers: Readonly> | undefined | null, + name: string, +): string { + if (!headers || typeof headers !== 'object') return ''; + const lower = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() !== lower) continue; + const value = headers[key]; + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value[0] ?? ''; + } + return ''; +} + // Collapse a HeaderEntry[] back into a V2-style header map. When `arr` is // true (the default — matches Xray's TCP/WS/HTTP request/response shape), // duplicate header names accumulate into a string[]. When false (used for diff --git a/frontend/src/test/headers.test.ts b/frontend/src/test/headers.test.ts index fd029a1f..a5ae1452 100644 --- a/frontend/src/test/headers.test.ts +++ b/frontend/src/test/headers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { toHeaders, toV2Headers, type HeaderEntry } from '@/lib/xray/headers'; +import { getHeaderValue, toHeaders, toV2Headers, type HeaderEntry } from '@/lib/xray/headers'; import { XrayCommonClass } from '@/models/inbound'; // Shadow harness: the new pure helpers must agree byte-for-byte with the @@ -57,3 +57,28 @@ describe('toV2Headers parity (arr=false)', () => { }); } }); + +describe('getHeaderValue lookups', () => { + it('returns empty string for missing map', () => { + expect(getHeaderValue(undefined, 'host')).toBe(''); + expect(getHeaderValue(null, 'host')).toBe(''); + expect(getHeaderValue({}, 'host')).toBe(''); + }); + + it('finds a string-valued header case-insensitively', () => { + expect(getHeaderValue({ Host: 'example.test' }, 'host')).toBe('example.test'); + expect(getHeaderValue({ host: 'example.test' }, 'HOST')).toBe('example.test'); + }); + + it('returns first value when the header is an array', () => { + expect(getHeaderValue({ Accept: ['text/html', 'application/json'] }, 'accept')).toBe('text/html'); + }); + + it('returns empty string when the header has empty array', () => { + expect(getHeaderValue({ Host: [] }, 'host')).toBe(''); + }); + + it('returns empty string for missing header name', () => { + expect(getHeaderValue({ Host: 'x' }, 'origin')).toBe(''); + }); +});