Sitelet https://github.com/angular/angular/pull/69043/commits/7b092b070759a8acfeb3d4f66557bad42261f6f2
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
fix(platform-server): throw on suspicious URLs and restrict protocol-…
…relative URLs

Backports the security fixes from:

- #68973

- #69018
  • Loading branch information
alan-agius4 committed Jun 1, 2026
commit 7b092b070759a8acfeb3d4f66557bad42261f6f2
19 changes: 17 additions & 2 deletions packages/platform-server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
import {inject, Injectable, Provider} from '@angular/core';
import {Observable} from 'rxjs';

import {parseUrl} from './url';

@Injectable()
export class ServerXhr implements XhrFactory {
private xhrImpl: typeof import('xhr2') | undefined;
Expand All @@ -41,10 +43,21 @@ export class ServerXhr implements XhrFactory {
}
}

/**
* Regex to match a URL schema.
*/
const URL_SCHEMA_REGEXP = /^(?:[a-zA-Z][a-zA-Z0-9+\-.]*:)/;

function relativeUrlsTransformerInterceptorFn(
request: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
const trimmedUrl = request.url.trim();
if (URL_SCHEMA_REGEXP.test(trimmedUrl)) {
// URLs with a schema should be left unchanged.
return next(request);
}

const platformLocation = inject(PlatformLocation);
const {href, protocol, hostname, port} = platformLocation;
if (!protocol.startsWith('http')) {
Expand All @@ -58,9 +71,11 @@ function relativeUrlsTransformerInterceptorFn(

const baseHref = platformLocation.getBaseHrefFromDOM() || href;
const baseUrl = new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2FbaseHref%2C%2520urlPrefix);
const newUrl = new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2Frequest.url%2C%2520baseUrl).toString();
const parsedUrl = parseUrl(request.url, baseUrl, {
allowProtocolRelative: true,
});

return next(request.clone({url: newUrl}));
return next(request.clone({url: parsedUrl.toString()}));
}

export const SERVER_HTTP_PROVIDERS: Provider[] = [
Expand Down
103 changes: 86 additions & 17 deletions packages/platform-server/src/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@
* found in the LICENSE file at https://angular.dev/license
*/

const LEADING_SLASHES_REGEX = /^[/\\]+/;
const MALFORMED_ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/;
/**
* Matches http: or https:
*/
const HTTP_OR_HTTPS_PROTOCOL_REGEX = /^https?:/i;

/**
* Options for {@link parseUrl}.
*/
export interface ParseUrlOptions {
/**
* Allow protocol-relative URLs (e.g. `//example.com`).
*/
allowProtocolRelative?: boolean;
}

/**
* Parses a URL string and returns a resolved WHATWG URL object.
Expand All @@ -16,32 +28,89 @@ const MALFORMED_ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/;
* 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 {
export function parseUrl(
urlStr: string | undefined,
origin: string | URL,
options?: ParseUrlOptions,
): URL;
export function parseUrl(
urlStr: string | undefined,
origin?: string | URL,
options: ParseUrlOptions = {},
): URL | null {
const originUrl = typeof origin === 'string' ? new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2F%26%2339%3B%2F%26%2339%3B%2C%2520origin) : origin;

if (!urlStr) {
return origin !== undefined ? new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2F%26%2339%3B%2F%26%2339%3B%2C%2520origin) : null;
return originUrl || null;
}

if (URL.canParse(urlStr)) {
return new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2FurlStr);
urlStr = urlStr.trim();

// Fast-path: if the URL is a valid, standard absolute URL, parse and return it immediately.
let resolved: URL | undefined;
try {
resolved = new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2FurlStr);
} catch {}

if (resolved) {
if (originUrl && !isSafeOriginChange(resolved, originUrl, urlStr)) {
throwSuspiciousUrlError(urlStr);
}

return resolved;
}

if (MALFORMED_ABSOLUTE_URL_REGEX.test(urlStr)) {
// We identify and throw on malformed absolute URLs (like double port).
// Per the WHATWG URL standard, parsing an input starting with a scheme (like 'http:') against
// a standard base (like 'http://fake') ignores the base argument and parses strictly as an
// absolute URL. Since it is malformed, the native URL constructor will throw a validation
// error. Standard relative/protocol-relative paths parse successfully, allowing the flow to continue.
if (!URL.canParse(urlStr, 'http://fake')) {
throw new Error(`Invalid URL: ${urlStr}`);
}

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

// Normalizes request path parsing by collapsing multiple consecutive leading slashes
// and backslashes (e.g. // or /\) down to a single forward slash. This ensures consistent
// resolution of relative path segments and prevents unexpected absolute path overrides
// during URL parsing.
let normalizedPath = urlStr.replace(LEADING_SLASHES_REGEX, '/');
if (normalizedPath[0] !== '/') {
normalizedPath = `/${normalizedPath}`;
const {allowProtocolRelative = false} = options;

// Check if we have a legitimate protocol-relative URL (starts with '//' and not a duplicate/backslash bypass)
// and we are configured to allow and preserve standard cross-origin protocol-relative requests.
if (urlStr.startsWith('//')) {
if (!allowProtocolRelative) {
throw new Error(`Protocol relative URLs are not allowed in this context. URL: ${urlStr}`);
}

return new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2FurlStr%2C%2520origin);
}

resolved = new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2FurlStr%2C%2520origin);

if (!isSafeOriginChange(resolved, originUrl, urlStr)) {
throwSuspiciousUrlError(urlStr);
}

return new url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fpull%2F69043%2Fcommits%2FnormalizedPath%2C%2520origin);
return resolved;
}

/**
* Throws a suspicious URL error indicating a security bypass attempt.
*/
function throwSuspiciousUrlError(urlStr: string): never {
throw new Error(
`URL ${urlStr} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`,
);
}

/**
* Checks if the origin has changed in a safe way.
*
* @param resolved The resolved URL.
* @param origin The origin URL.
* @param urlStr The URL string.
* @returns True if the origin has changed in a safe way, false otherwise.
*/
function isSafeOriginChange(resolved: URL, origin: URL, urlStr: string): boolean {
return origin.origin === resolved.origin || HTTP_OR_HTTPS_PROTOCOL_REGEX.test(urlStr);
}
62 changes: 62 additions & 0 deletions packages/platform-server/test/integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1537,6 +1537,68 @@ class HiddenModule {}
mock.expectOne('http://localhost/testing').flush('success!');
});
});

it('prevents SSRF bypasses via backslash URLs in HttpClient by throwing a suspicious origin error', async () => {
ref.injector.get(NgZone).run(() => {
http.get('/\\evil.com/api').subscribe({
next: () => fail('Expected request to fail, but it succeeded.'),
error: (err) => {
expect(err.message).toBe(
`URL /\\evil.com/api changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`,
);
},
});

mock.verify();
});
});

it('should reject backslash bypass SSRF attempts in relative requests and throw a suspicious origin error', async () => {
const badUrls = [
'/\\attacker.com',
'\\\\attacker.com',
' /\\attacker.com',
'\r\n/\\attacker.com',
];

ref.injector.get(NgZone).run(() => {
for (const badUrl of badUrls) {
http.get(badUrl).subscribe({
next: () => fail(`Expected request for ${badUrl} to fail, but it succeeded.`),
error: (err) => {
expect(err.message).toBe(
`URL ${badUrl.trim()} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`,
);
},
});
}

mock.verify();
});
});

it('should reject obfuscated protocal SSRF attempts in relative requests and throw a suspicious origin error', async () => {
const badUrls = [
'htt\rps://evil.com/path',
' htt\rps://evil.com/path',
'\r\nhtt\rps://evil.com/path',
];

ref.injector.get(NgZone).run(() => {
for (const badUrl of badUrls) {
http.get(badUrl).subscribe({
next: () => fail(`Expected request for ${badUrl} to fail, but it succeeded.`),
error: (err) => {
expect(err.message).toBe(
`URL ${badUrl.trim()} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`,
);
},
});
}

mock.verify();
});
});
});
});
});
Expand Down
109 changes: 27 additions & 82 deletions packages/platform-server/test/platform_location_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {INITIAL_CONFIG, platformServer} from '@angular/platform-server';
expect(location.pathname).toBe('/');
platform.destroy();
});

it('is configurable via INITIAL_CONFIG', async () => {
const platform = platformServer([
{
Expand Down Expand Up @@ -135,94 +136,38 @@ import {INITIAL_CONFIG, platformServer} from '@angular/platform-server';
location.pushState(null, 'Test', '/foo#bar');
});

it('neutralizes hostname hijack attempts', async () => {
const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];

for (const url of urls) {
const platform = platformServer([
{
provide: INITIAL_CONFIG,
useValue: {
document: '',
// This should be treated as relative URL.
// Example: `req.url: '//attacker.com/deep/path'` where request
// to express server is 'http://localhost:4200//attacker.com/deep/path'.
url,
},
it('should throw on hostname hijack attempts to prevent origin hijack', async () => {
const platform = platformServer([
{
provide: INITIAL_CONFIG,
useValue: {
document: '<html><head></head><body></body></html>',
url: '/\\attacker.com/deep/path',
},
]);

const location = platform.injector.get(PlatformLocation);
platform.destroy();
},
]);

expect(location.hostname).withContext(`hostname for URL: "${url}"`).toBe('');
expect(location.pathname)
.withContext(`pathname for URL: "${url}"`)
.toBe('/attacker.com/deep/path');
}
expect(() => platform.injector.get(DOCUMENT)).toThrowError(
`URL /\\attacker.com/deep/path changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`,
);
platform.destroy();
});

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,
},
it('should throw on protocol-relative URLs in INITIAL_CONFIG', async () => {
const platform = platformServer([
{
provide: INITIAL_CONFIG,
useValue: {
document: '<html><head></head><body></body></html>',
url: '//attacker.com/deep/path',
},
]);

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();
}
}
expect(() => platform.injector.get(DOCUMENT)).toThrowError(
`Protocol relative URLs are not allowed in this context. URL: //attacker.com/deep/path`,
);
platform.destroy();
});
});
})();
Loading
Loading