Skip to content
Open
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
2 changes: 2 additions & 0 deletions goldens/public-api/router/errors.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
OUTLET_NOT_ACTIVATED = 4012,
// (undocumented)
PROTOCOL_RELATIVE_URL_NOT_ALLOWED = 4019,
// (undocumented)
ROOT_SEGMENT_MATRIX_PARAMS = 4003,
// (undocumented)
TWO_SEGMENTS_WITH_SAME_OUTLET = 4006,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"DI_DECORATOR_FLAG",
"DOCUMENT",
"DOCUMENT2",
"DUMMY_BASE_URL",
"DefaultDomRenderer2",
"DefaultRouteReuseStrategy",
"DefaultTitleStrategy",
Expand Down Expand Up @@ -846,6 +847,7 @@
"isPositive",
"isPromise",
"isPromise2",
"isProtocolRelative",
"isPublicRouterEvent",
"isReadableStreamLike",
"isRedirect",
Expand Down
1 change: 1 addition & 0 deletions packages/router/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ export const enum RuntimeErrorCode {
INFINITE_REDIRECT = 4016,
INVALID_ROUTER_LINK_INPUTS = 4017,
ERROR_PARSING_URL = 4018,
PROTOCOL_RELATIVE_URL_NOT_ALLOWED = 4019,
}
36 changes: 34 additions & 2 deletions packages/router/src/url_tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {Injectable, ɵRuntimeError as RuntimeError} from '@angular/core';
import {
ɵformatRuntimeError as formatRuntimeError,
Injectable,
ɵRuntimeError as RuntimeError,
} from '@angular/core';

import {RuntimeErrorCode} from './errors';
import {convertToParamMap, ParamMap, Params, PRIMARY_OUTLET} from './shared';
Expand Down Expand Up @@ -402,7 +406,18 @@ export class DefaultUrlSerializer implements UrlSerializer {

/** Converts a `UrlTree` into a url */
serialize(tree: UrlTree): string {
const segment = `/${serializeSegment(tree.root, true)}`;
let segment = `/${serializeSegment(tree.root, true)}`;
if (isProtocolRelative(segment)) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
console.warn(
formatRuntimeError(
RuntimeErrorCode.PROTOCOL_RELATIVE_URL_NOT_ALLOWED,
`Cannot serialize a UrlTree that would produce a protocol-relative URL. Falling back to '/' instead.`,
),
);
}
segment = '/';
}
const query = serializeQueryParams(tree.queryParams);
const fragment =
typeof tree.fragment === `string` ? `#${encodeUriFragment(tree.fragment)}` : '';
Expand All @@ -411,6 +426,23 @@ export class DefaultUrlSerializer implements UrlSerializer {
}
}

const DUMMY_BASE_URL = 'http://fake';

/**
* Determines whether a serialized path would produce a protocol-relative URL when interpreted
* by a browser or server. Under the WHATWG URL standard, paths starting with `//` or `/\`, or paths
* where leading dot segments collapse to `//` (such as `/.//` or `/..//`), resolve to an external
* origin or a protocol-relative pathname.
*/
function isProtocolRelative(url: string): boolean {
try {
const resolved = new URL(url, DUMMY_BASE_URL);
return resolved.origin !== DUMMY_BASE_URL || resolved.pathname.startsWith('//');
} catch {
return true;
}
}

const DEFAULT_SERIALIZER = new DefaultUrlSerializer();

export function serializePaths(segment: UrlSegmentGroup): string {
Expand Down
86 changes: 86 additions & 0 deletions packages/router/test/create_url_tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ import {timeout} from './helpers';

describe('createUrlTree', () => {
const serializer = new DefaultUrlSerializer();
const protocolRelativeUrlWarning = `NG04019: Cannot serialize a UrlTree that would produce a protocol-relative URL. Falling back to '/' instead.`;

function expectProtocolRelativeUrlFallback(tree: UrlTree, expected = '/'): void {
const warn = spyOn(console, 'warn');

expect(serializer.serialize(tree)).toEqual(expected);
expect(warn).toHaveBeenCalledOnceWith(protocolRelativeUrlWarning);
}

let router: Router;
beforeEach(() => {
router = TestBed.inject(Router);
Expand Down Expand Up @@ -116,6 +125,83 @@ describe('createUrlTree', () => {
expect(serializer.serialize(t)).toEqual('/%2Fone/two%2Fthree');
});

describe('leading empty path commands', () => {
it('should fall back for absolute navigations that would serialize as protocol-relative', () => {
const t = router.createUrlTree(['/', '', '', 'attacker.example', 'collect']);

expectProtocolRelativeUrlFallback(t);
});

it('should fall back for an unsafe primary outlet string', async () => {
await router.navigateByUrl('/safe');
const t = router.createUrlTree([{outlets: {primary: '/attacker.example/collect'}}]);

expectProtocolRelativeUrlFallback(t);
});

it('should fall back for an unsafe primary outlet array', () => {
const t = router.createUrlTree([{outlets: {primary: ['', 'attacker.example', 'collect']}}]);

expectProtocolRelativeUrlFallback(t);
});

it('should fall back for unsafe parent-relative commands', async () => {
router.resetConfig([{path: 'source', component: class {}}]);
await router.navigateByUrl('/source');
const t = create(router.routerState.root.firstChild!, [
'../',
'',
'attacker.example',
'collect',
]);

expectProtocolRelativeUrlFallback(t);
});

it('should fall back for an escaped slash after an empty path command', () => {
const t = router.createUrlTree(['/', '', {segmentPath: '/'}]);

expectProtocolRelativeUrlFallback(t);
});

it('should fall back for final empty path commands', () => {
const t = router.createUrlTree(['/', '', '']);

expectProtocolRelativeUrlFallback(t);
});

it('should fall back for dot segments that normalize to protocol-relative', () => {
const t = router.createUrlTree(['/', '.', '', 'attacker.example', 'collect']);

expectProtocolRelativeUrlFallback(t);
});

it('should fall back for parent dot segments that normalize to protocol-relative', () => {
const t = router.createUrlTree(['/', '..', '', 'attacker.example', 'collect']);

expectProtocolRelativeUrlFallback(t);
});

it('should preserve query params and fragment when falling back', () => {
const t = router.createUrlTree(['/', '', '', 'attacker.example', 'collect'], {
queryParams: {foo: 'bar'},
fragment: 'frag',
});

expectProtocolRelativeUrlFallback(t, '/?foo=bar#frag');
});

it('should not normalize a leading empty path command in a secondary outlet', () => {
const t = router.createUrlTree(['/', {outlets: {right: ['', 'child']}}]);

expect(t.root.children['right'].segments.map((segment) => segment.path)).toEqual([
'',
'child',
]);
expect(serializer.serialize(t)).toEqual('/(right:/child)');
});
});

describe('named outlets', () => {
it('should preserve secondary segments', async () => {
const p = serializer.parse('/a/11/b(right:c)');
Expand Down
47 changes: 47 additions & 0 deletions packages/router/test/router_link_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,51 @@ describe('RouterLink', () => {
const fixture = TestBed.createComponent(WithUrlTree);
expect(() => fixture.changeDetectorRef.detectChanges()).toThrow();
});
it('falls back to the root for a link that would generate a protocol-relative href', async () => {
@Component({
template: `<a [routerLink]="commands" queryParamsHandling="preserve">commands</a>`,
imports: [RouterLink],
})
class WithLink {
readonly commands = ['/', '', 'attacker.example', 'collect'];
}

TestBed.configureTestingModule({
providers: [provideRouter([{path: '', component: WithLink}])],
});
const warn = spyOn(console, 'warn');
const fixture = TestBed.createComponent(WithLink);

await fixture.whenStable();

expect(fixture.nativeElement.querySelector('a').getAttribute('href')).toBe('/');
expect(warn).toHaveBeenCalledWith(
`NG04019: Cannot serialize a UrlTree that would produce a protocol-relative URL. Falling back to '/' instead.`,
);
});

it('preserves query params and fragment when falling back for a protocol-relative link', async () => {
@Component({
template: `<a [routerLink]="commands" [queryParams]="{ref: '123'}" fragment="section"
>commands</a
>`,
imports: [RouterLink],
})
class WithLink {
readonly commands = ['/', '', 'attacker.example', 'collect'];
}

TestBed.configureTestingModule({
providers: [provideRouter([{path: '', component: WithLink}])],
});
const warn = spyOn(console, 'warn');
const fixture = TestBed.createComponent(WithLink);

await fixture.whenStable();

expect(fixture.nativeElement.querySelector('a').getAttribute('href')).toBe('/?ref=123#section');
expect(warn).toHaveBeenCalledWith(
`NG04019: Cannot serialize a UrlTree that would produce a protocol-relative URL. Falling back to '/' instead.`,
);
});
});
67 changes: 67 additions & 0 deletions packages/router/test/url_serializer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import {
encodeUriQuery,
encodeUriSegment,
serializePath,
UrlSegment,
UrlSegmentGroup,
} from '../src/url_tree';

describe('url serializer', () => {
const url = new DefaultUrlSerializer();
const protocolRelativeUrlWarning = `NG04019: Cannot serialize a UrlTree that would produce a protocol-relative URL. Falling back to '/' instead.`;

it('should parse the root url', () => {
const tree = url.parse('/');
Expand Down Expand Up @@ -417,6 +419,71 @@ describe('url serializer', () => {
});
});

describe('leading empty path segments', () => {
it('should fall back for a parsed primary outlet that would serialize as protocol-relative', () => {
const warn = spyOn(console, 'warn');
const tree = url.parse('/(primary://attacker.example/collect)?token=RESET_TOKEN');

expect(url.serialize(tree)).toBe('/?token=RESET_TOKEN');
expect(warn).toHaveBeenCalledOnceWith(protocolRelativeUrlWarning);
});

it('should fall back for multiple leading empty primary segments', () => {
const warn = spyOn(console, 'warn');
const tree = url.parse('/attacker.example/collect');
tree.root.children[PRIMARY_OUTLET].segments.unshift(
new UrlSegment('', {}),
new UrlSegment('', {}),
);

expect(url.serialize(tree)).toBe('/');
expect(warn).toHaveBeenCalledOnceWith(protocolRelativeUrlWarning);
});

it('should fall back for unsafe trees with secondary outlets, query params, and fragments', () => {
const warn = spyOn(console, 'warn');
const tree = url.parse(
'/attacker.example/collect(popup:compose)?token=RESET_TOKEN#OAUTH_TOKEN',
);
tree.root.children[PRIMARY_OUTLET].segments.unshift(new UrlSegment('', {}));

expect(url.serialize(tree)).toBe('/?token=RESET_TOKEN#OAUTH_TOKEN');
expect(warn).toHaveBeenCalledOnceWith(protocolRelativeUrlWarning);
});

it('should fall back for a parsed path with dot and leading empty segment that would normalize to protocol-relative', () => {
const warn = spyOn(console, 'warn');
const tree = url.parse('/.;/(//evil.test)');

expect(url.serialize(tree)).toBe('/');
expect(warn).toHaveBeenCalledOnceWith(protocolRelativeUrlWarning);
});

it('should fall back for dot segments collapsing to protocol-relative path', () => {
const warn = spyOn(console, 'warn');
const tree = url.parse('/attacker.example/collect');
tree.root.children[PRIMARY_OUTLET].segments.unshift(
new UrlSegment('.', {}),
new UrlSegment('', {}),
);

expect(url.serialize(tree)).toBe('/');
expect(warn).toHaveBeenCalledOnceWith(protocolRelativeUrlWarning);
});

it('should fall back for double dot segments collapsing to protocol-relative path', () => {
const warn = spyOn(console, 'warn');
const tree = url.parse('/attacker.example/collect');
tree.root.children[PRIMARY_OUTLET].segments.unshift(
new UrlSegment('..', {}),
new UrlSegment('', {}),
);

expect(url.serialize(tree)).toBe('/');
expect(warn).toHaveBeenCalledOnceWith(protocolRelativeUrlWarning);
});
});

describe('error handling', () => {
it('should throw when invalid characters inside children', () => {
expect(() => url.parse('/one/(left#one)')).toThrowError();
Expand Down
Loading