Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions packages/angular/ssr/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,26 +95,32 @@ export function addTrailingSlash(url: string): string {
* ```
*/
export function joinUrlParts(...parts: string[]): string {
Copy link
Collaborator

@dgp1130 dgp1130 Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: Could a regex be simpler? Something like:

const regex = new Regex('^/*(?<part>.*?)/*$');
const normalizedParts = parts.map((part) => regex.exec(part).groups['part']);
return addLeadingSlash(normalizedParts.join('/'));

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite a hot function, whilst I didn’t benchmark it I assume the above is much slower.

const normalizeParts: string[] = [];
const normalizedParts: string[] = [];

for (const part of parts) {
if (part === '') {
// Skip any empty parts
continue;
}

let normalizedPart = part;
if (part[0] === '/') {
normalizedPart = normalizedPart.slice(1);
let start = 0;
let end = part.length;

// Use "Pointers" to avoid intermediate slices
while (start < end && part[start] === '/') {
start++;
}
if (part.at(-1) === '/') {
normalizedPart = normalizedPart.slice(0, -1);

while (end > start && part[end - 1] === '/') {
end--;
}
if (normalizedPart !== '') {
normalizeParts.push(normalizedPart);

if (start < end) {
normalizedParts.push(part.slice(start, end));
}
}

return addLeadingSlash(normalizeParts.join('/'));
return addLeadingSlash(normalizedParts.join('/'));
}

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/angular/ssr/src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const VALID_PROTO_REGEX = /^https?$/i;
*/
const VALID_HOST_REGEX = /^[a-z0-9.:-]+$/i;

/**
* Regular expression to validate that the prefix is valid.
*/
const INVALID_PREFIX_REGEX = /^[/\\]{2}|(?:^|[/\\])\.\.?(?:[/\\]|$)/;

/**
* Extracts the first value from a multi-value header string.
*
Expand Down Expand Up @@ -253,4 +258,11 @@ function validateHeaders(request: Request): void {
if (xForwardedProto && !VALID_PROTO_REGEX.test(xForwardedProto)) {
throw new Error('Header "x-forwarded-proto" must be either "http" or "https".');
}

const xForwardedPrefix = getFirstHeaderValue(headers.get('x-forwarded-prefix'));
if (xForwardedPrefix && INVALID_PREFIX_REGEX.test(xForwardedPrefix)) {
throw new Error(
'Header "x-forwarded-prefix" must not start with multiple "/" or "\\" or contain ".", ".." path segments.',
);
}
}
12 changes: 12 additions & 0 deletions packages/angular/ssr/test/utils/url_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ describe('URL Utils', () => {
it('should handle an all-empty URL parts', () => {
expect(joinUrlParts('', '')).toBe('/');
});

it('should normalize parts with multiple leading and trailing slashes', () => {
expect(joinUrlParts('//path//', '///to///', '//resource//')).toBe('/path/to/resource');
});

it('should handle a single part', () => {
expect(joinUrlParts('path')).toBe('/path');
});

it('should handle parts containing only slashes', () => {
expect(joinUrlParts('//', '///')).toBe('/');
});
});

describe('stripIndexHtmlFromURL', () => {
Expand Down
67 changes: 67 additions & 0 deletions packages/angular/ssr/test/utils/validation_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,73 @@ describe('Validation Utils', () => {
'Header "x-forwarded-host" contains characters that are not allowed.',
);
});

it('should throw error if x-forwarded-prefix starts with multiple slashes or backslashes', () => {
const inputs = ['//evil', '\\\\evil', '/\\evil', '\\/evil'];

for (const prefix of inputs) {
const request = new Request('https://example.com', {
headers: {
'x-forwarded-prefix': prefix,
},
});

expect(() => validateRequest(request, allowedHosts))
.withContext(`Prefix: "${prefix}"`)
.toThrowError(
'Header "x-forwarded-prefix" must not start with multiple "/" or "\\" or contain ".", ".." path segments.',
);
}
});

it('should throw error if x-forwarded-prefix contains dot segments', () => {
const inputs = [
'/./',
'/../',
'/foo/./bar',
'/foo/../bar',
'/.',
'/..',
'./',
'../',
'.\\',
'..\\',
'/foo/.\\bar',
'/foo/..\\bar',
'.',
'..',
];

for (const prefix of inputs) {
const request = new Request('https://example.com', {
headers: {
'x-forwarded-prefix': prefix,
},
});

expect(() => validateRequest(request, allowedHosts))
.withContext(`Prefix: "${prefix}"`)
.toThrowError(
'Header "x-forwarded-prefix" must not start with multiple "/" or "\\" or contain ".", ".." path segments.',
);
}
});

it('should validate x-forwarded-prefix with valid dot usage', () => {
const inputs = ['/foo.bar', '/foo.bar/baz', '/v1.2', '/.well-known'];

for (const prefix of inputs) {
const request = new Request('https://example.com', {
headers: {
'x-forwarded-prefix': prefix,
},
});

expect(() => validateRequest(request, allowedHosts))
.withContext(`Prefix: "${prefix}"`)
.not.toThrow();
}
});
});

describe('cloneRequestAndPatchHeaders', () => {
Expand Down