Skip to content

Commit 9552252

Browse files
alan-agius4alxhub
authored andcommitted
fix(platform-server): secure location and document initialization against SSRF and path hijack
Normalizes the URL and path parsing logic inside platform-server by consolidating security checks and normalizations into a single, unified parseUrl helper function. This includes: - Collapsing multiple consecutive leading slashes and backslashes (e.g., // or /\) to a single forward slash to avoid protocol-relative parsing of path-like & relative inputs. - Rejecting malformed absolute URLs that are otherwise accepted by lenient DOM parsers like Domino but rejected by standard WHATWG parsers, preventing SSRF / allowedHosts validation bypasses. - Ensuring parseDocument gets the fully parsed and normalized URL instead of raw, unvalidated configuration values, preventing virtual document hostname adoption/origin hijack. - Moving parseUrl unit tests into a dedicated url_spec.ts test file to keep platform_location_spec.ts clean and decoupled. (cherry picked from commit 1307ff3)
1 parent 7d1fbc1 commit 9552252

7 files changed

Lines changed: 240 additions & 43 deletions

File tree

packages/platform-server/src/location.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,7 @@ import {inject, Injectable, ɵWritable as Writable} from '@angular/core';
1717
import {Subject} from 'rxjs';
1818

1919
import {INITIAL_CONFIG} from './tokens';
20-
21-
/**
22-
* Parses a URL string and returns a URL object.
23-
* @param urlStr The string to parse.
24-
* @param origin The origin to use for resolving the URL.
25-
* @returns The parsed URL.
26-
*/
27-
export function parseUrl(urlStr: string, origin: string): URL {
28-
if (URL.canParse(urlStr)) {
29-
return new URL(urlStr);
30-
}
31-
32-
if (urlStr && urlStr[0] !== '/') {
33-
urlStr = `/${urlStr}`;
34-
}
35-
36-
return new URL(origin + urlStr);
37-
}
20+
import {parseUrl} from './url';
3821

3922
/**
4023
* Server-side implementation of URL state. Implements `pathname`, `search`, and `hash`

packages/platform-server/src/server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737

3838
import {DominoAdapter, parseDocument} from './domino_adapter';
3939
import {SERVER_HTTP_PROVIDERS} from './http';
40+
import {parseUrl} from './url';
4041
import {ServerPlatformLocation} from './location';
4142
import {enableDomEmulation, PlatformState} from './platform_state';
4243
import {ServerEventManagerPlugin} from './server_events';
@@ -100,7 +101,10 @@ function _document() {
100101
document =
101102
typeof config.document === 'string'
102103
? _enableDomEmulation
103-
? parseDocument(config.document, config.url)
104+
? parseDocument(
105+
config.document,
106+
config.url !== undefined ? parseUrl(config.url, 'http://localhost').href : undefined,
107+
)
104108
: window.document
105109
: config.document;
106110
} else {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
const LEADING_SLASHES_REGEX = /^[/\\]+/;
10+
11+
/**
12+
* Parses a URL string and returns a resolved WHATWG URL object.
13+
* If no origin is provided, it parses and returns the URL only if it is a valid absolute URL;
14+
* otherwise it returns `null` (or throws if the URL is a malformed absolute URL).
15+
* If an origin is provided, relative URLs and protocol-relative URLs are normalized and resolved against it.
16+
*/
17+
export function parseUrl(urlStr: string | undefined): URL | null;
18+
export function parseUrl(urlStr: string | undefined, origin: string): URL;
19+
export function parseUrl(urlStr: string | undefined, origin?: string): URL | null {
20+
if (!urlStr) {
21+
return origin !== undefined ? new URL('/', origin) : null;
22+
}
23+
24+
if (URL.canParse(urlStr)) {
25+
return new URL(urlStr);
26+
}
27+
28+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/.test(urlStr)) {
29+
throw new Error(`Invalid URL: ${urlStr}`);
30+
}
31+
32+
if (origin === undefined) {
33+
return null;
34+
}
35+
36+
let normalizedPath = urlStr.replace(LEADING_SLASHES_REGEX, '/');
37+
if (normalizedPath[0] !== '/') {
38+
normalizedPath = `/${normalizedPath}`;
39+
}
40+
41+
return new URL(normalizedPath, origin);
42+
}

packages/platform-server/src/utils.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {platformServer} from './server';
2929
import {PlatformState} from './platform_state';
3030
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
3131
import {createScript} from './transfer_state';
32+
import {parseUrl} from './url';
3233

3334
/**
3435
* Event dispatch (JSAction) script is inlined into the HTML by the build
@@ -377,11 +378,14 @@ export async function renderApplication(
377378
}
378379

379380
function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) {
380-
if (typeof url === 'string' && URL.canParse(url)) {
381-
const hostname = new URL(url).hostname;
382-
const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
383-
if (!isHostAllowed(hostname, allowedHostsSet)) {
384-
throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
381+
if (typeof url === 'string') {
382+
const parsedUrl = parseUrl(url);
383+
if (parsedUrl !== null) {
384+
const hostname = parsedUrl.hostname;
385+
const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
386+
if (!isHostAllowed(hostname, allowedHostsSet)) {
387+
throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
388+
}
385389
}
386390
}
387391
}

packages/platform-server/test/platform_location_spec.ts

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,13 @@
77
*/
88
import '@angular/compiler';
99

10-
import {PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
10+
import {DOCUMENT, PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
1111
import {destroyPlatform} from '@angular/core';
1212
import {INITIAL_CONFIG, platformServer} from '@angular/platform-server';
1313

14-
import {parseUrl} from '../src/location';
15-
1614
(function () {
1715
if (getDOM().supportsDOMEvents) return; // NODE only
1816

19-
describe('parseUrl', () => {
20-
it('should resolve relative paths against origin', () => {
21-
const url = parseUrl('/deep/path?query#hash', 'http://test.com');
22-
expect(url.href).toBe('http://test.com/deep/path?query#hash');
23-
expect(url.search).toBe('?query');
24-
expect(url.hash).toBe('#hash');
25-
});
26-
27-
it('should resolve absolute URLs ignoring origin', () => {
28-
const url = parseUrl('http://other.com/deep/path', 'http://test.com');
29-
expect(url.href).toBe('http://other.com/deep/path');
30-
expect(url.origin).toBe('http://other.com');
31-
});
32-
});
33-
3417
describe('PlatformLocation', () => {
3518
beforeEach(() => {
3619
destroyPlatform();
@@ -173,7 +156,72 @@ import {parseUrl} from '../src/location';
173156
platform.destroy();
174157

175158
expect(location.hostname).withContext(`hostname for URL: "${url}"`).toBe('');
176-
expect(location.pathname).withContext(`pathname for URL: "${url}"`).toBe(url);
159+
expect(location.pathname)
160+
.withContext(`pathname for URL: "${url}"`)
161+
.toBe('/attacker.com/deep/path');
162+
}
163+
});
164+
165+
it('should set the proper document location when the URL has leading slashes to prevent origin hijack', async () => {
166+
const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];
167+
168+
for (const url of urls) {
169+
const platform = platformServer([
170+
{
171+
provide: INITIAL_CONFIG,
172+
useValue: {
173+
document: '<html><head></head><body></body></html>',
174+
url,
175+
},
176+
},
177+
]);
178+
179+
const doc = platform.injector.get(DOCUMENT);
180+
platform.destroy();
181+
182+
expect(doc.location.origin).not.toBe('http://attacker.com');
183+
expect(doc.location.pathname).toBe('/attacker.com/deep/path');
184+
}
185+
});
186+
187+
it('should not expose protocol-relative URLs on the location to prevent open redirect and SSRF bypasses', async () => {
188+
const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];
189+
const origins = [undefined, 'http://localhost:4200'];
190+
191+
for (const url of urls) {
192+
for (const origin of origins) {
193+
const providers: any[] = [
194+
{
195+
provide: INITIAL_CONFIG,
196+
useValue: {
197+
document: '',
198+
url,
199+
},
200+
},
201+
];
202+
203+
if (origin) {
204+
providers.push({
205+
provide: DOCUMENT,
206+
useValue: {
207+
location: {
208+
origin,
209+
},
210+
},
211+
});
212+
}
213+
214+
const platform = platformServer(providers);
215+
const location = platform.injector.get(PlatformLocation) as any;
216+
platform.destroy();
217+
218+
// A relative redirect URL starting with // or /\ is normalized by browsers to a protocol-relative URL.
219+
// The PlatformLocation.url property MUST NOT expose these unsafe patterns.
220+
const isVulnerable = location.url.startsWith('//') || location.url.startsWith('/\\');
221+
expect(isVulnerable)
222+
.withContext(`URL: "${url}", origin: "${origin}", location.url: "${location.url}"`)
223+
.toBeFalse();
224+
}
177225
}
178226
});
179227
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {parseUrl} from '../src/url';
10+
11+
describe('parseUrl', () => {
12+
describe('with origin', () => {
13+
it('should resolve relative paths against origin', () => {
14+
const url = parseUrl('/deep/path?query#hash', 'http://test.com');
15+
expect(url.href).toBe('http://test.com/deep/path?query#hash');
16+
expect(url.search).toBe('?query');
17+
expect(url.hash).toBe('#hash');
18+
});
19+
20+
it('should resolve absolute URLs ignoring origin', () => {
21+
const url = parseUrl('http://other.com/deep/path', 'http://test.com');
22+
expect(url.href).toBe('http://other.com/deep/path');
23+
expect(url.origin).toBe('http://other.com');
24+
});
25+
26+
it('should throw an error for malformed absolute URLs', () => {
27+
const malformedUrls = [
28+
'http://evil.com:80:80/path',
29+
'https://evil.com:80:80/path',
30+
'http://[google.com]/path',
31+
'http://google.com:port/path',
32+
'http://google.com:80a/path',
33+
];
34+
35+
for (const url of malformedUrls) {
36+
expect(() => parseUrl(url, 'http://test.com')).toThrowError(
37+
new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
38+
);
39+
}
40+
});
41+
});
42+
43+
describe('without origin', () => {
44+
it('should return null for relative paths', () => {
45+
expect(parseUrl('/deep/path?query#hash')).toBeNull();
46+
expect(parseUrl('deep/path')).toBeNull();
47+
});
48+
49+
it('should parse valid absolute URLs', () => {
50+
const url = parseUrl('http://other.com/deep/path');
51+
expect(url).not.toBeNull();
52+
expect(url!.href).toBe('http://other.com/deep/path');
53+
expect(url!.origin).toBe('http://other.com');
54+
});
55+
56+
it('should throw an error for malformed absolute URLs', () => {
57+
const malformedUrls = [
58+
'http://evil.com:80:80/path',
59+
'https://evil.com:80:80/path',
60+
'http://[google.com]/path',
61+
'http://google.com:port/path',
62+
'http://google.com:80a/path',
63+
];
64+
65+
for (const url of malformedUrls) {
66+
expect(() => parseUrl(url)).toThrowError(
67+
new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
68+
);
69+
}
70+
});
71+
});
72+
});

packages/platform-server/test/utils_spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,28 @@ describe('allowedHosts validation in renderApplication', () => {
6060
expect(error.message).not.toContain('is not allowed');
6161
}
6262
});
63+
64+
it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => {
65+
const malformedUrls = [
66+
'http://evil.com:80:80/path',
67+
'https://evil.com:80:80/path',
68+
'http://[google.com]/path',
69+
'http://google.com:port/path',
70+
'http://google.com:80a/path',
71+
];
72+
73+
for (const url of malformedUrls) {
74+
await expectAsync(
75+
renderApplication(bootstrap, {
76+
document: '<app></app>',
77+
url,
78+
allowedHosts: ['test.com'],
79+
}),
80+
)
81+
.withContext(`URL: ${url}`)
82+
.toBeRejectedWithError(new RegExp(/Invalid URL:.+/));
83+
}
84+
});
6385
});
6486

6587
describe('allowedHosts validation in renderModule', () => {
@@ -94,4 +116,26 @@ describe('allowedHosts validation in renderModule', () => {
94116
expect(error.message).not.toContain('is not allowed');
95117
}
96118
});
119+
120+
it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => {
121+
const malformedUrls = [
122+
'http://evil.com:80:80/path',
123+
'https://evil.com:80:80/path',
124+
'http://[google.com]/path',
125+
'http://google.com:port/path',
126+
'http://google.com:80a/path',
127+
];
128+
129+
for (const url of malformedUrls) {
130+
await expectAsync(
131+
renderModule(MockModule, {
132+
document: '<app></app>',
133+
url,
134+
allowedHosts: ['test.com'],
135+
}),
136+
)
137+
.withContext(`URL: ${url}`)
138+
.toBeRejectedWithError(new RegExp(/Invalid URL:.+/));
139+
}
140+
});
97141
});

0 commit comments

Comments
 (0)