Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

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

オープン
#34,207 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
52/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
活発
技術スタック
angular, typescript
領域
backend, security

調査の方向性

Reproduce the redirect with the AngularNodeAppEngine setup and curl commands in the issue, then inspect the @angular/ssr redirect assembly and ServerPlatformLocation.replaceState() entry points. Compare the normalized values used for the final-URL check with the pathname emitted in the redirect. Done means the supplied parameter-route cases no longer produce a cross-origin Location while same-origin redirects continue to work.

索引モデルが issue の本文から書いたものです。

説明

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(target) 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.

主要言語
TypeScript
スター
27k
フォーク
11.8k
平均マージ
22時間 50分
マージ済み PR(30日)
168

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

angular/angular-cli のほかの issue

angular/angular-cli の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。