fix(router): limit protocol-relative URL handling to serialization

Preserve createUrlTree command semantics, including custom serializer inputs, while keeping the single-leading-slash guarantee at the default serialization boundary.

Expand coverage for command forms, public UrlTree values, secondary outlets, and preserved query parameters and fragments.

Fixes #69700

(cherry picked from commit 435f8b2b8b)
This commit is contained in:
SkyZeroZx
2026-07-22 12:46:52 -05:00
committed by Alon Mishne
parent 65e2aed04f
commit 2f82601662
6 changed files with 118 additions and 0 deletions
+2
View File
@@ -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,
+1
View File
@@ -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,
}
+7
View File
@@ -454,6 +454,13 @@ export class DefaultUrlSerializer implements UrlSerializer {
/** Converts a `UrlTree` into a url */
serialize(tree: UrlTree): string {
const segment = `/${serializeSegment(tree.root, true)}`;
if (segment.startsWith('//')) {
throw new RuntimeError(
RuntimeErrorCode.PROTOCOL_RELATIVE_URL_NOT_ALLOWED,
(typeof ngDevMode === 'undefined' || ngDevMode) &&
'Cannot serialize a UrlTree that would produce a protocol-relative URL.',
);
}
const query = serializeQueryParams(tree.queryParams);
const fragment =
typeof tree.fragment === `string` ? `#${encodeUriFragment(tree.fragment)}` : '';
@@ -22,6 +22,8 @@ import {timeout} from '@angular/private/testing';
describe('createUrlTree', () => {
const serializer = new DefaultUrlSerializer();
const protocolRelativeUrlError =
/NG04019: Cannot serialize a UrlTree that would produce a protocol-relative URL/;
let router: Router;
beforeEach(() => {
router = TestBed.inject(Router);
@@ -134,6 +136,62 @@ describe('createUrlTree', () => {
expect(serializer.serialize(t)).toEqual('/%2Fone/two%2Fthree');
});
describe('leading empty path commands', () => {
it('should reject absolute navigations that would serialize as protocol-relative', () => {
const t = router.createUrlTree(['/', '', '', 'attacker.example', 'collect']);
expect(() => serializer.serialize(t)).toThrowError(protocolRelativeUrlError);
});
it('should reject an unsafe primary outlet string', async () => {
await router.navigateByUrl('/safe');
const t = router.createUrlTree([{outlets: {primary: '/attacker.example/collect'}}]);
expect(() => serializer.serialize(t)).toThrowError(protocolRelativeUrlError);
});
it('should reject an unsafe primary outlet array', () => {
const t = router.createUrlTree([{outlets: {primary: ['', 'attacker.example', 'collect']}}]);
expect(() => serializer.serialize(t)).toThrowError(protocolRelativeUrlError);
});
it('should reject 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',
]);
expect(() => serializer.serialize(t)).toThrowError(protocolRelativeUrlError);
});
it('should reject an escaped slash after an empty path command', () => {
const t = router.createUrlTree(['/', '', {segmentPath: '/'}]);
expect(() => serializer.serialize(t)).toThrowError(protocolRelativeUrlError);
});
it('should reject final empty path commands', () => {
const t = router.createUrlTree(['/', '', '']);
expect(() => serializer.serialize(t)).toThrowError(protocolRelativeUrlError);
});
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)');
+20
View File
@@ -329,4 +329,24 @@ describe('RouterLink', () => {
await harness.navigateByUrl('/different');
expect(anchor.getAttribute('href')).toBe('/different/child');
});
it('rejects 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 fixture = TestBed.createComponent(WithLink);
await expectAsync(fixture.whenStable()).toBeRejectedWithError(
/NG04019: Cannot serialize a UrlTree that would produce a protocol-relative URL/,
);
expect(fixture.nativeElement.querySelector('a').getAttribute('href')).toBeNull();
});
});
@@ -13,11 +13,14 @@ import {
encodeUriQuery,
encodeUriSegment,
serializePath,
UrlSegment,
UrlSegmentGroup,
} from '../src/url_tree';
describe('url serializer', () => {
const url = new DefaultUrlSerializer();
const protocolRelativeUrlError =
/NG04019: Cannot serialize a UrlTree that would produce a protocol-relative URL/;
it('should parse the root url', () => {
const tree = url.parse('/');
@@ -447,6 +450,33 @@ describe('url serializer', () => {
});
});
describe('leading empty path segments', () => {
it('should reject a parsed primary outlet that would serialize as protocol-relative', () => {
const tree = url.parse('/(primary://attacker.example/collect)?token=RESET_TOKEN');
expect(() => url.serialize(tree)).toThrowError(protocolRelativeUrlError);
});
it('should reject multiple leading empty primary segments', () => {
const tree = url.parse('/attacker.example/collect');
tree.root.children[PRIMARY_OUTLET].segments.unshift(
new UrlSegment('', {}),
new UrlSegment('', {}),
);
expect(() => url.serialize(tree)).toThrowError(protocolRelativeUrlError);
});
it('should reject unsafe trees with secondary outlets, query params, and fragments', () => {
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)).toThrowError(protocolRelativeUrlError);
});
});
describe('error handling', () => {
it('should throw when invalid characters inside children', () => {
expect(() => url.parse('/one/(left#one)')).toThrowError();