Skip to content

Commit b986347

Browse files
committed
fixup! feat(@angular/ssr): support the standard Forwarded header
1 parent 9b93c32 commit b986347

2 files changed

Lines changed: 206 additions & 9 deletions

File tree

packages/angular/ssr/src/utils/validation.ts

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -297,21 +297,124 @@ export function parseForwardedHeader(
297297
return {};
298298
}
299299

300-
const firstElement = headerValue.split(',', 1)[0];
301300
const params: Record<string, string> = {};
302-
const paramRegex = /([^\s;=]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;\s]*)/gi;
301+
let inQuotes = false;
302+
let escaped = false;
303+
let currentKey = '';
304+
let currentValue = '';
305+
let isParsingValue = false;
306+
let isKeyEnded = false;
307+
let isParsingValueEnded = false;
308+
309+
for (const char of headerValue) {
310+
if (escaped) {
311+
escaped = false;
312+
if (isParsingValue) {
313+
currentValue += char;
314+
} else {
315+
currentKey += char;
316+
}
317+
continue;
318+
}
319+
320+
if (char === '\\') {
321+
if (inQuotes) {
322+
escaped = true;
323+
} else if (isParsingValue) {
324+
currentValue += char;
325+
} else {
326+
currentKey += char;
327+
}
328+
continue;
329+
}
330+
331+
if (char === '"') {
332+
inQuotes = !inQuotes;
333+
continue;
334+
}
303335

304-
let match;
305-
while ((match = paramRegex.exec(firstElement)) !== null) {
306-
const key = match[1].toLowerCase();
307-
let val = match[2];
336+
if (inQuotes) {
337+
if (isParsingValue) {
338+
currentValue += char;
339+
} else {
340+
currentKey += char;
341+
}
342+
continue;
343+
}
344+
345+
if (char === ',') {
346+
addParam(currentKey, currentValue, isParsingValue, params);
347+
break;
348+
}
308349

309-
if (val[0] === '"' && val.at(-1) === '"') {
310-
val = val.slice(1, -1).replace(/\\(.)/g, '$1');
350+
if (char === ';') {
351+
addParam(currentKey, currentValue, isParsingValue, params);
352+
currentKey = '';
353+
currentValue = '';
354+
isParsingValue = false;
355+
isKeyEnded = false;
356+
isParsingValueEnded = false;
357+
continue;
358+
}
359+
360+
if (char === '=') {
361+
if (!isParsingValue) {
362+
isParsingValue = true;
363+
} else {
364+
currentValue += char;
365+
}
366+
continue;
311367
}
312368

313-
params[key] = val;
369+
if (char === ' ' || char === '\t') {
370+
if (isParsingValue) {
371+
if (currentValue.length > 0) {
372+
isParsingValueEnded = true;
373+
}
374+
} else if (currentKey.length > 0) {
375+
isKeyEnded = true;
376+
}
377+
continue;
378+
}
379+
380+
if (isParsingValue) {
381+
if (!isParsingValueEnded) {
382+
currentValue += char;
383+
}
384+
} else if (isKeyEnded) {
385+
currentKey = char;
386+
isKeyEnded = false;
387+
} else {
388+
currentKey += char;
389+
}
390+
}
391+
392+
if (currentKey || currentValue || isParsingValue) {
393+
addParam(currentKey, currentValue, isParsingValue, params);
314394
}
315395

316396
return params;
317397
}
398+
399+
/**
400+
* Helper function to add a parameter to the params object.
401+
* @param key - The key to add.
402+
* @param value - The value to add.
403+
* @param hasValue - Whether the parameter has a value.
404+
* @param params - The params object to add the parameter to.
405+
*/
406+
function addParam(
407+
key: string,
408+
value: string,
409+
hasValue: boolean,
410+
params: Record<string, string>,
411+
): void {
412+
if (!hasValue) {
413+
return;
414+
}
415+
416+
const trimmedKey = key.trim().toLowerCase();
417+
if (trimmedKey) {
418+
params[trimmedKey] = value;
419+
}
420+
}

packages/angular/ssr/test/utils/validation_spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {
1010
getFirstHeaderValue,
1111
normalizeTrustProxyHeaders,
12+
parseForwardedHeader,
1213
sanitizeRequestHeaders,
1314
validateRequest,
1415
validateUrl,
@@ -38,6 +39,99 @@ describe('Validation Utils', () => {
3839
});
3940
});
4041

42+
describe('parseForwardedHeader', () => {
43+
it('should return an empty object for null, undefined, or empty string', () => {
44+
expect(parseForwardedHeader(null)).toEqual({});
45+
expect(parseForwardedHeader(undefined)).toEqual({});
46+
expect(parseForwardedHeader('')).toEqual({});
47+
});
48+
49+
it('should parse simple parameters correctly', () => {
50+
expect(parseForwardedHeader('host=example.com;proto=https')).toEqual({
51+
host: 'example.com',
52+
proto: 'https',
53+
});
54+
});
55+
56+
it('should handle case-insensitivity of parameter names', () => {
57+
expect(parseForwardedHeader('Host=example.com;PROTO=https')).toEqual({
58+
host: 'example.com',
59+
proto: 'https',
60+
});
61+
});
62+
63+
it('should handle whitespace around separators', () => {
64+
expect(parseForwardedHeader(' host = example.com ; proto = https ')).toEqual({
65+
host: 'example.com',
66+
proto: 'https',
67+
});
68+
});
69+
70+
it('should parse quoted strings correctly and remove outer quotes', () => {
71+
expect(parseForwardedHeader('host="example.com";proto="https"')).toEqual({
72+
host: 'example.com',
73+
proto: 'https',
74+
});
75+
});
76+
77+
it('should handle escaped characters inside quoted strings', () => {
78+
expect(parseForwardedHeader('host="example.com\\"escaped\\"";proto=https')).toEqual({
79+
host: 'example.com"escaped"',
80+
proto: 'https',
81+
});
82+
});
83+
84+
it('should handle semicolons inside quoted strings correctly', () => {
85+
expect(parseForwardedHeader('host="example.com;with;semicolons";proto=https')).toEqual({
86+
host: 'example.com;with;semicolons',
87+
proto: 'https',
88+
});
89+
});
90+
91+
it('should handle commas inside quoted strings correctly without splitting the element', () => {
92+
expect(parseForwardedHeader('host="example.com,with,commas";proto=https')).toEqual({
93+
host: 'example.com,with,commas',
94+
proto: 'https',
95+
});
96+
});
97+
98+
it('should only parse the first (leftmost) element in a comma-separated list', () => {
99+
expect(
100+
parseForwardedHeader('host=example.com;proto=https, for=192.0.2.60;proto=http'),
101+
).toEqual({
102+
host: 'example.com',
103+
proto: 'https',
104+
});
105+
});
106+
107+
it('should ignore parameters without values (no equals sign)', () => {
108+
expect(parseForwardedHeader('host; proto=https')).toEqual({
109+
proto: 'https',
110+
});
111+
});
112+
113+
it('should handle empty parameter values', () => {
114+
expect(parseForwardedHeader('host=; proto=https')).toEqual({
115+
host: '',
116+
proto: 'https',
117+
});
118+
});
119+
120+
it('should treat spaces inside unquoted values as token terminators and ignore subsequent garbage', () => {
121+
expect(parseForwardedHeader('host=example.com extra; proto=https')).toEqual({
122+
host: 'example.com',
123+
proto: 'https',
124+
});
125+
});
126+
127+
it('should treat spaces inside parameter names as token terminators and discard the invalid prefix', () => {
128+
expect(parseForwardedHeader('ho st=value; proto=https')).toEqual({
129+
st: 'value',
130+
proto: 'https',
131+
});
132+
});
133+
});
134+
41135
describe('normalizeTrustProxyHeaders', () => {
42136
it('should return an empty set when input is undefined', () => {
43137
expect(normalizeTrustProxyHeaders(undefined)).toEqual(new Set());

0 commit comments

Comments
 (0)