Angular SSR pathname normalization can turn a same-origin navigation into an open redirect
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 52/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- angular, typescript
調査の方向性
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
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
angular/angular-cli のほかの issue
-
area: @angular/build gemini-triaged
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
angular/angular-cli#33955 ·
メンテナーはふだん 1 日以内に返信
-
area: @angular/cli gemini-triaged
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
angular/angular-cli#33055 · コメント 1 件 · リアクション 3 件 ·
メンテナーはふだん 1 日以内に返信
-
area: @angular/cli gemini-triaged
難易度 3/5 1〜2日 初心者へのやさしさ 55/100
angular/angular-cli#34215 ·
メンテナーはふだん 1 日以内に返信
-
stats.json is non-deterministic when two component stylesheets produce the same output file対応中かも @sdjayna が今日担当しました。 オープンarea: @angular/build gemini-triaged
難易度 2/5 1〜3時間 初心者へのやさしさ 35/100
angular/angular-cli#34209 ·
メンテナーはふだん 1 日以内に返信
-
area: @angular/build gemini-triaged
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
angular/angular-cli#34198 ·
メンテナーはふだん 1 日以内に返信
angular/angular-cli の issue をすべて見る
似ている issue
-
area/frontend good first issue kind/cooldown
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
voidzero-dev/oxc-angular-compiler#511 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
langchain-ai/deepagentsjs#898 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 92/100
anomalyco/models.dev#8509 · コメント 2 件 ·
メンテナーはふだん 1 日以内に返信
-
bug documentation P2 UI/UX
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
メンテナーはふだん 1 日以内に返信