diff --git a/goldens/public-api/router/errors.api.md b/goldens/public-api/router/errors.api.md index 0ed6714f7a1..95a36f8abcc 100644 --- a/goldens/public-api/router/errors.api.md +++ b/goldens/public-api/router/errors.api.md @@ -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, diff --git a/packages/router/src/errors.ts b/packages/router/src/errors.ts index 734d56e1c80..a8cc313a683 100644 --- a/packages/router/src/errors.ts +++ b/packages/router/src/errors.ts @@ -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, } diff --git a/packages/router/src/url_tree.ts b/packages/router/src/url_tree.ts index ee6853cc087..c729b9a3ddf 100644 --- a/packages/router/src/url_tree.ts +++ b/packages/router/src/url_tree.ts @@ -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)}` : ''; diff --git a/packages/router/test/create_url_tree.spec.ts b/packages/router/test/create_url_tree.spec.ts index 8132136d1ed..764a26f963a 100644 --- a/packages/router/test/create_url_tree.spec.ts +++ b/packages/router/test/create_url_tree.spec.ts @@ -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)'); diff --git a/packages/router/test/router_link_spec.ts b/packages/router/test/router_link_spec.ts index c675a03a561..8d880c2bfb2 100644 --- a/packages/router/test/router_link_spec.ts +++ b/packages/router/test/router_link_spec.ts @@ -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: `commands`, + 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(); + }); }); diff --git a/packages/router/test/url_serializer.spec.ts b/packages/router/test/url_serializer.spec.ts index 70329510ab0..cdb1a5444c2 100644 --- a/packages/router/test/url_serializer.spec.ts +++ b/packages/router/test/url_serializer.spec.ts @@ -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();