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
7 changes: 5 additions & 2 deletions packages/router/src/navigation_transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import {
} from './router_state';
import type {Params} from './shared';
import {UrlHandlingStrategy} from './url_handling_strategy';
import {UrlSerializer, UrlTree} from './url_tree';
import {canonicalizeUrlTree, UrlSerializer, UrlTree} from './url_tree';
import {abortSignalToObservable} from './utils/abort_signal_to_observable';
import {Checks, getAllRouteGuards} from './utils/preactivation';
import {CREATE_VIEW_TRANSITION} from './utils/view_transition';
Expand Down Expand Up @@ -428,7 +428,10 @@ export class NavigationTransitions {
untracked(() => {
this.transitions?.next({
...request,
extractedUrl: this.urlHandlingStrategy.extract(request.rawUrl),
extractedUrl: canonicalizeUrlTree(
this.urlHandlingStrategy.extract(request.rawUrl),
this.urlSerializer,
),
targetSnapshot: null,
targetRouterState: null,
guards: {canActivateChecks: [], canDeactivateChecks: []},
Expand Down
26 changes: 25 additions & 1 deletion packages/router/src/url_tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,9 @@ export class DefaultUrlSerializer implements UrlSerializer {

/** Converts a `UrlTree` into a url */
serialize(tree: UrlTree): string {
const segment = `/${serializeSegment(tree.root, true)}`;
// A public UrlTree can contain a leading empty primary segment. Always emit a root-relative URL
// rather than allowing the serialized path to become protocol-relative.
const segment = `/${serializeSegment(tree.root, true).replace(/^\/+/, '')}`;
const query = serializeQueryParams(tree.queryParams);
const fragment =
typeof tree.fragment === `string` ? `#${encodeUriFragment(tree.fragment)}` : '';
Expand All @@ -464,6 +466,28 @@ export class DefaultUrlSerializer implements UrlSerializer {

const DEFAULT_SERIALIZER = new DefaultUrlSerializer();

/**
* Reparse trees whose leading empty segments are collapsed by the default serializer so route
* recognition and the browser URL use the same segment structure.
*/
export function canonicalizeUrlTree(tree: UrlTree, serializer: UrlSerializer): UrlTree {
const primary = tree.root.children[PRIMARY_OUTLET];
const startsWithEmptyPath =
primary !== undefined &&
(primary.segments.length > 0
? serializePath(primary.segments[0]) === ''
: primary.hasChildren());
if (!startsWithEmptyPath) {
return tree;
}

const {queryParams, fragment} = tree;
const canonical = serializer.parse(serializer.serialize(tree));
canonical.queryParams = queryParams;
canonical.fragment = fragment;
return canonical;
}

export function serializePaths(segment: UrlSegmentGroup): string {
return segment.segments.map((p) => serializePath(p)).join('/');
}
Expand Down
56 changes: 56 additions & 0 deletions packages/router/test/create_url_tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,62 @@ describe('createUrlTree', () => {
expect(serializer.serialize(t)).toEqual('/%2Fone/two%2Fthree');
});

describe('leading empty path command serialization', () => {
it('should serialize absolute navigations with one leading slash', () => {
const t = router.createUrlTree(['/', '', '', 'attacker.example', 'collect']);

expect(serializer.serialize(t)).toEqual('/attacker.example/collect');
});

it('should serialize a primary outlet string with one leading slash', async () => {
await router.navigateByUrl('/safe');
const t = router.createUrlTree([{outlets: {primary: '/attacker.example/collect'}}]);

expect(serializer.serialize(t)).toEqual('/attacker.example/collect');
});

it('should serialize a primary outlet array with one leading slash', () => {
const t = router.createUrlTree([{outlets: {primary: ['', 'attacker.example', 'collect']}}]);

expect(serializer.serialize(t)).toEqual('/attacker.example/collect');
});

it('should serialize parent-relative commands with one leading slash', 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)).toEqual('/attacker.example/collect');
});

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

expect(serializer.serialize(t)).toEqual('/%2F');
});

it('should serialize final empty path commands as the root URL', () => {
const t = router.createUrlTree(['/', '', '']);

expect(serializer.serialize(t)).toEqual('/');
});

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
12 changes: 12 additions & 0 deletions packages/router/test/integration/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ export function navigationIntegrationTestSuite(browserAPI: 'history' | 'navigati
return TestBed.inject(Router);
}
describe('navigation', () => {
it('recognizes the route represented by the serialized URL', async () => {
const router = setup([
{path: ':tenant/admin', component: SimpleCmp},
{path: 'admin', component: SimpleCmp},
]);

await router.navigateByUrl('/(primary://admin)');

expect(router.url).toBe('/admin');
expect(router.routerState.snapshot.root.firstChild?.routeConfig?.path).toBe('admin');
});

it('should navigate to the current URL', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({onSameUrlNavigation: 'reload'}))],
Expand Down
111 changes: 110 additions & 1 deletion packages/router/test/router_link_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
import {Component, inject, signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {Router, RouterLink, RouterModule, provideRouter} from '../index';
import {
PRIMARY_OUTLET,
Router,
RouterLink,
RouterModule,
UrlSegment,
provideRouter,
} from '../index';
import {RouterTestingHarness} from '../testing';

describe('RouterLink', () => {
Expand Down Expand Up @@ -329,4 +336,106 @@ describe('RouterLink', () => {
await harness.navigateByUrl('/different');
expect(anchor.getAttribute('href')).toBe('/different/child');
});

it('does not generate protocol-relative hrefs from leading empty primary segments', async () => {
@Component({
template: `
<a id="commands" [routerLink]="commands" queryParamsHandling="preserve">commands</a>
<a
id="outlet-string"
[routerLink]="outletString"
[queryParams]="pageQueryParams"
queryParamsHandling="merge"
>
outlet string
</a>
<a
id="outlet-array"
[routerLink]="outletArray"
queryParamsHandling="preserve"
preserveFragment
>
outlet array
</a>
<a id="url-tree" [routerLink]="urlTree">UrlTree</a>
`,
imports: [RouterLink],
})
class WithLink {
readonly commands = ['/', '', 'attacker.example', 'collect'];
readonly outletString = [{outlets: {primary: '/attacker.example/collect'}}];
readonly outletArray = [{outlets: {primary: ['', 'attacker.example', 'collect']}}];
readonly pageQueryParams = {page: 1};
readonly urlTree = inject(Router).parseUrl(
'/attacker.example/collect?token=RESET_TOKEN#OAUTH_TOKEN',
);

constructor() {
// UrlTree segments are public, so this mutation exercises the serializer backstop.
this.urlTree.root.children[PRIMARY_OUTLET].segments.unshift(new UrlSegment('', {}));
}
}

TestBed.configureTestingModule({
providers: [provideRouter([{path: '', component: WithLink}])],
});
const harness = await RouterTestingHarness.create('/?token=RESET_TOKEN#OAUTH_TOKEN');

expect(harness.fixture.nativeElement.querySelector('#commands').getAttribute('href')).toBe(
'/attacker.example/collect?token=RESET_TOKEN',
);
expect(harness.fixture.nativeElement.querySelector('#outlet-string').getAttribute('href')).toBe(
'/attacker.example/collect?token=RESET_TOKEN&page=1',
);
expect(harness.fixture.nativeElement.querySelector('#outlet-array').getAttribute('href')).toBe(
'/attacker.example/collect?token=RESET_TOKEN#OAUTH_TOKEN',
);
expect(harness.fixture.nativeElement.querySelector('#url-tree').getAttribute('href')).toBe(
'/attacker.example/collect?token=RESET_TOKEN#OAUTH_TOKEN',
);
});

it('navigates to the route represented by the href', async () => {
const tenantAdminGuard = jasmine.createSpy().and.returnValue(true);
const strictAdminGuard = jasmine.createSpy().and.returnValue(true);

@Component({template: ''})
class RoutedComponent {}

@Component({
template: `
<a [routerLink]="['/', '', 'admin']" [queryParams]="{token: 'secret'}" fragment="fragment">
admin
</a>
`,
imports: [RouterLink],
})
class WithLink {}

TestBed.configureTestingModule({
providers: [
provideRouter([
{
path: ':tenant/admin',
canActivate: [tenantAdminGuard],
component: RoutedComponent,
},
{path: 'admin', canActivate: [strictAdminGuard], component: RoutedComponent},
]),
],
});
const fixture = TestBed.createComponent(WithLink);
await fixture.whenStable();

const anchor: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
expect(anchor.getAttribute('href')).toBe('/admin?token=secret#fragment');
anchor.click();
await fixture.whenStable();

const router = TestBed.inject(Router);
expect(router.url).toBe('/admin?token=secret#fragment');
expect(router.routerState.snapshot.root.firstChild?.routeConfig?.path).toBe('admin');
expect(strictAdminGuard).toHaveBeenCalled();
expect(tenantAdminGuard).not.toHaveBeenCalled();
});
});
30 changes: 30 additions & 0 deletions packages/router/test/url_serializer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
encodeUriQuery,
encodeUriSegment,
serializePath,
UrlSegment,
UrlSegmentGroup,
} from '../src/url_tree';

Expand Down Expand Up @@ -447,6 +448,35 @@ describe('url serializer', () => {
});
});

describe('leading empty path segments', () => {
it('should not serialize a parsed primary outlet as a protocol-relative URL', () => {
const tree = url.parse('/(primary://attacker.example/collect)?token=RESET_TOKEN');

expect(url.serialize(tree)).toEqual('/attacker.example/collect?token=RESET_TOKEN');
});

it('should emit one slash for 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)).toEqual('/attacker.example/collect');
});

it('should preserve secondary outlets, query params, and fragments when serializing', () => {
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)).toEqual(
'/attacker.example/collect(popup:compose)?token=RESET_TOKEN#OAUTH_TOKEN',
);
});
});

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