Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
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
19 changes: 1 addition & 18 deletions 19 packages/platform-server/src/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,7 @@ import {Inject, Injectable, Optional, ɵWritable as Writable} from '@angular/cor
import {Subject} from 'rxjs';

import {INITIAL_CONFIG, PlatformConfig} from './tokens';

/**
* Parses a URL string and returns a URL object.
* @param urlStr The string to parse.
* @param origin The origin to use for resolving the URL.
* @returns The parsed URL.
*/
export function parseUrl(urlStr: string, origin: string): URL {
if (URL.canParse(urlStr)) {
return new URL(urlStr);
}

if (urlStr && urlStr[0] !== '/') {
urlStr = `/${urlStr}`;
}

return new URL(origin + urlStr);
}
import {parseUrl} from './url';

/**
* Server-side implementation of URL state. Implements `pathname`, `search`, and `hash`
Expand Down
6 changes: 5 additions & 1 deletion 6 packages/platform-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {

import {DominoAdapter, parseDocument} from './domino_adapter';
import {SERVER_HTTP_PROVIDERS} from './http';
import {parseUrl} from './url';
import {ServerPlatformLocation} from './location';
import {enableDomEmulation, PlatformState} from './platform_state';
import {ServerEventManagerPlugin} from './server_events';
Expand Down Expand Up @@ -98,7 +99,10 @@ function _document(injector: Injector) {
document =
typeof config.document === 'string'
? _enableDomEmulation
? parseDocument(config.document, config.url)
? parseDocument(
config.document,
config.url !== undefined ? parseUrl(config.url, 'http://localhost').href : undefined,
)
: window.document
: config.document;
} else {
Expand Down
42 changes: 42 additions & 0 deletions 42 packages/platform-server/src/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

const LEADING_SLASHES_REGEX = /^[/\\]+/;

/**
* Parses a URL string and returns a resolved WHATWG URL object.
* If no origin is provided, it parses and returns the URL only if it is a valid absolute URL;
* otherwise it returns `null` (or throws if the URL is a malformed absolute URL).
* If an origin is provided, relative URLs and protocol-relative URLs are normalized and resolved against it.
*/
export function parseUrl(urlStr: string | undefined): URL | null;
export function parseUrl(urlStr: string | undefined, origin: string): URL;
export function parseUrl(urlStr: string | undefined, origin?: string): URL | null {
if (!urlStr) {
return origin !== undefined ? new URL('/', origin) : null;
}

if (URL.canParse(urlStr)) {
return new URL(urlStr);
}

if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/.test(urlStr)) {
throw new Error(`Invalid URL: ${urlStr}`);
}

if (origin === undefined) {
return null;
}

let normalizedPath = urlStr.replace(LEADING_SLASHES_REGEX, '/');
if (normalizedPath[0] !== '/') {
normalizedPath = `/${normalizedPath}`;
}

return new URL(normalizedPath, origin);
}
14 changes: 9 additions & 5 deletions 14 packages/platform-server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {platformServer} from './server';
import {PlatformState} from './platform_state';
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
import {createScript} from './transfer_state';
import {parseUrl} from './url';

/**
* Event dispatch (JSAction) script is inlined into the HTML by the build
Expand Down Expand Up @@ -374,11 +375,14 @@ export async function renderApplication(
}

function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) {
if (typeof url === 'string' && URL.canParse(url)) {
const hostname = new URL(url).hostname;
const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
if (!isHostAllowed(hostname, allowedHostsSet)) {
throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
if (typeof url === 'string') {
const parsedUrl = parseUrl(url);
if (parsedUrl !== null) {
const hostname = parsedUrl.hostname;
const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
if (!isHostAllowed(hostname, allowedHostsSet)) {
throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
}
}
}
}
Expand Down
86 changes: 67 additions & 19 deletions 86 packages/platform-server/test/platform_location_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,13 @@
*/
import '@angular/compiler';

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

import {parseUrl} from '../src/location';

(function () {
if (getDOM().supportsDOMEvents) return; // NODE only

describe('parseUrl', () => {
it('should resolve relative paths against origin', () => {
const url = parseUrl('/deep/path?query#hash', 'http://test.com');
expect(url.href).toBe('http://test.com/deep/path?query#hash');
expect(url.search).toBe('?query');
expect(url.hash).toBe('#hash');
});

it('should resolve absolute URLs ignoring origin', () => {
const url = parseUrl('http://other.com/deep/path', 'http://test.com');
expect(url.href).toBe('http://other.com/deep/path');
expect(url.origin).toBe('http://other.com');
});
});

describe('PlatformLocation', () => {
beforeEach(() => {
destroyPlatform();
Expand Down Expand Up @@ -173,7 +156,72 @@ import {parseUrl} from '../src/location';
platform.destroy();

expect(location.hostname).withContext(`hostname for URL: "${url}"`).toBe('');
expect(location.pathname).withContext(`pathname for URL: "${url}"`).toBe(url);
expect(location.pathname)
.withContext(`pathname for URL: "${url}"`)
.toBe('/attacker.com/deep/path');
}
});

it('should set the proper document location when the URL has leading slashes to prevent origin hijack', async () => {
const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];

for (const url of urls) {
const platform = platformServer([
{
provide: INITIAL_CONFIG,
useValue: {
document: '<html><head></head><body></body></html>',
url,
},
},
]);

const doc = platform.injector.get(DOCUMENT);
platform.destroy();

expect(doc.location.origin).not.toBe('http://attacker.com');
expect(doc.location.pathname).toBe('/attacker.com/deep/path');
}
});

it('should not expose protocol-relative URLs on the location to prevent open redirect and SSRF bypasses', async () => {
const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];
const origins = [undefined, 'http://localhost:4200'];

for (const url of urls) {
for (const origin of origins) {
const providers: any[] = [
{
provide: INITIAL_CONFIG,
useValue: {
document: '',
url,
},
},
];

if (origin) {
providers.push({
provide: DOCUMENT,
useValue: {
location: {
origin,
},
},
});
}

const platform = platformServer(providers);
const location = platform.injector.get(PlatformLocation) as any;
platform.destroy();

// A relative redirect URL starting with // or /\ is normalized by browsers to a protocol-relative URL.
// The PlatformLocation.url property MUST NOT expose these unsafe patterns.
const isVulnerable = location.url.startsWith('//') || location.url.startsWith('/\\');
expect(isVulnerable)
.withContext(`URL: "${url}", origin: "${origin}", location.url: "${location.url}"`)
.toBeFalse();
}
}
});
});
Expand Down
72 changes: 72 additions & 0 deletions 72 packages/platform-server/test/url_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {parseUrl} from '../src/url';

describe('parseUrl', () => {
describe('with origin', () => {
it('should resolve relative paths against origin', () => {
const url = parseUrl('/deep/path?query#hash', 'http://test.com');
expect(url.href).toBe('http://test.com/deep/path?query#hash');
expect(url.search).toBe('?query');
expect(url.hash).toBe('#hash');
});

it('should resolve absolute URLs ignoring origin', () => {
const url = parseUrl('http://other.com/deep/path', 'http://test.com');
expect(url.href).toBe('http://other.com/deep/path');
expect(url.origin).toBe('http://other.com');
});

it('should throw an error for malformed absolute URLs', () => {
const malformedUrls = [
'http://evil.com:80:80/path',
'https://evil.com:80:80/path',
'http://[google.com]/path',
'http://google.com:port/path',
'http://google.com:80a/path',
];

for (const url of malformedUrls) {
expect(() => parseUrl(url, 'http://test.com')).toThrowError(
new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
);
}
});
});

describe('without origin', () => {
it('should return null for relative paths', () => {
expect(parseUrl('/deep/path?query#hash')).toBeNull();
expect(parseUrl('deep/path')).toBeNull();
});

it('should parse valid absolute URLs', () => {
const url = parseUrl('http://other.com/deep/path');
expect(url).not.toBeNull();
expect(url!.href).toBe('http://other.com/deep/path');
expect(url!.origin).toBe('http://other.com');
});

it('should throw an error for malformed absolute URLs', () => {
const malformedUrls = [
'http://evil.com:80:80/path',
'https://evil.com:80:80/path',
'http://[google.com]/path',
'http://google.com:port/path',
'http://google.com:80a/path',
];

for (const url of malformedUrls) {
expect(() => parseUrl(url)).toThrowError(
new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
);
}
});
});
});
44 changes: 44 additions & 0 deletions 44 packages/platform-server/test/utils_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ describe('allowedHosts validation in renderApplication', () => {
expect(error.message).not.toContain('is not allowed');
}
});

it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => {
const malformedUrls = [
'http://evil.com:80:80/path',
'https://evil.com:80:80/path',
'http://[google.com]/path',
'http://google.com:port/path',
'http://google.com:80a/path',
];

for (const url of malformedUrls) {
await expectAsync(
renderApplication(bootstrap, {
document: '<app></app>',
url,
allowedHosts: ['test.com'],
}),
)
.withContext(`URL: ${url}`)
.toBeRejectedWithError(new RegExp(/Invalid URL:.+/));
}
});
});

describe('allowedHosts validation in renderModule', () => {
Expand Down Expand Up @@ -94,4 +116,26 @@ describe('allowedHosts validation in renderModule', () => {
expect(error.message).not.toContain('is not allowed');
}
});

it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => {
const malformedUrls = [
'http://evil.com:80:80/path',
'https://evil.com:80:80/path',
'http://[google.com]/path',
'http://google.com:port/path',
'http://google.com:80a/path',
];

for (const url of malformedUrls) {
await expectAsync(
renderModule(MockModule, {
document: '<app></app>',
url,
allowedHosts: ['test.com'],
}),
)
.withContext(`URL: ${url}`)
.toBeRejectedWithError(new RegExp(/Invalid URL:.+/));
}
});
});
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.