diff --git a/aastar-frontend/app/auth/login/page.tsx b/aastar-frontend/app/auth/login/page.tsx index c822efe..b56cb6d 100644 --- a/aastar-frontend/app/auth/login/page.tsx +++ b/aastar-frontend/app/auth/login/page.tsx @@ -1,19 +1,30 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import Layout from "@/components/Layout"; import { authAPI } from "@/lib/api"; import { kmsClient } from "@/lib/yaaa"; import { setStoredAuth } from "@/lib/auth"; +import { safeReturnPath } from "@/lib/safe-return-path"; import toast from "react-hot-toast"; import { startAuthentication, startRegistration } from "@simplewebauthn/browser"; const isEmail = (v: string) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v); +/** Where to land after a successful sign-in when `?redirect=` is absent or unusable. */ +const DEFAULT_LANDING = "/dashboard"; + export default function LoginPage() { const [loading, setLoading] = useState(false); + // Read in an effect (not during render): `window` doesn't exist while prerendering, and + // useSearchParams would force this page behind a Suspense boundary. + const [returnTo, setReturnTo] = useState(DEFAULT_LANDING); + useEffect(() => { + const safe = safeReturnPath(new URLSearchParams(window.location.search).get("redirect")); + if (safe) setReturnTo(safe); + }, []); const [loginMode, setLoginMode] = useState<"passkey" | "otp">("passkey"); const [email, setEmail] = useState(""); const [otpEmail, setOtpEmail] = useState(""); @@ -54,7 +65,7 @@ export default function LoginPage() { toast.dismiss(t); setStoredAuth(access_token, user); toast.success("Welcome back!"); - router.push("/dashboard"); + router.push(returnTo); } catch (error: any) { if (t) toast.dismiss(t); @@ -163,7 +174,7 @@ export default function LoginPage() { } else { toast.success("Signed in!"); } - router.push("/dashboard"); + router.push(returnTo); } catch (error: any) { if (t) toast.dismiss(t); setWalletStatus(null); diff --git a/aastar-frontend/app/sso/start/page.tsx b/aastar-frontend/app/sso/start/page.tsx new file mode 100644 index 0000000..023e239 --- /dev/null +++ b/aastar-frontend/app/sso/start/page.tsx @@ -0,0 +1,207 @@ +/** + * cos72 SSO authorize kickoff (MV-7, cos72 half of the MyVote SSO handoff). + * + * This page is a *transition*, not a destination — there is nothing to click on the happy + * path. MyVote sends the browser here with `?redirect_uri=https:///sso/callback`, and: + * + * 1. no cos72 session → bounce to /auth/login?redirect=, + * so the handoff resumes automatically after sign-in; + * 2. session present → POST /sso/authorize { redirect_uri } with the cos72 JWT (MV-1); + * 3. 200 → location.replace(`${redirectUri}?code=${code}`) — MyVote then + * swaps the one-time code for an SSO token via POST /sso/exchange; + * 4. 400 / 401 /网络 → an explicit error card (never a silent dead end), with copy an + * operator can act on (the whitelist case names SSO_ALLOWED_REDIRECTS). + * + * OPEN-REDIRECT NOTE: the code is appended to `redirectUri` **as echoed back by the backend**, + * never to the raw `?redirect_uri` query value. The backend validates it fail-closed against + * SSO_ALLOWED_REDIRECTS and returns its normalized form, so an attacker-supplied redirect_uri + * can only ever produce a 400 here — this page performs no whitelist logic of its own (a + * frontend check would be both bypassable and a second source of truth). + * + * @module app/sso/start/page + */ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import Layout from "@/components/Layout"; +import { ssoAPI } from "@/lib/api"; +import { isAuthenticated } from "@/lib/auth"; + +/** Distinguishable failure modes — each maps to its own i18n message. */ +type ErrorKind = + | "missingRedirect" // no ?redirect_uri in the URL + | "notWhitelisted" // 400: redirect_uri not in SSO_ALLOWED_REDIRECTS (ops-actionable) + | "noAccount" // 400: the cos72 user has no AirAccount yet + | "unauthorized" // 401: cos72 session missing/expired/rejected + | "badRedirect" // 200 but the echoed redirectUri doesn't parse (should never happen) + | "network" // request never got a response + | "unknown"; // any other backend error + +type Phase = { status: "working" } | { status: "error"; kind: ErrorKind; detail?: string }; + +/** The backend's whitelist rejection — matched on the env var name it names in its message. */ +const WHITELIST_HINT = /SSO_ALLOWED_REDIRECTS/i; +const NO_ACCOUNT_HINT = /AirAccount/i; + +export default function SsoStartPage() { + const { t } = useTranslation(); + const router = useRouter(); + const [phase, setPhase] = useState({ status: "working" }); + const [redirectUri, setRedirectUri] = useState(""); + // React 18/19 StrictMode double-invokes effects in dev; minting a second one-time code is + // harmless (60s TTL, unused codes just expire) but pointless — run the handoff once. + const startedRef = useRef(false); + + const start = useCallback(async () => { + const raw = new URLSearchParams(window.location.search).get("redirect_uri"); + if (!raw) { + setPhase({ status: "error", kind: "missingRedirect" }); + return; + } + setRedirectUri(raw); + + if (!isAuthenticated()) { + // Preserve the whole query string so login can hand control straight back here. + const back = `/sso/start?redirect_uri=${encodeURIComponent(raw)}`; + router.replace(`/auth/login?redirect=${encodeURIComponent(back)}`); + return; + } + + try { + const { data } = await ssoAPI.authorize(raw); + let target: URL; + try { + // Build on the backend-echoed (validated + normalized) URI, and use URLSearchParams so + // the `code` is appended with "?" or "&" correctly even when it already carries a query. + target = new URL(data.redirectUri); + } catch { + setPhase({ status: "error", kind: "badRedirect", detail: data.redirectUri }); + return; + } + target.searchParams.set("code", data.code); + // replace(), not assign(): the SSO start URL must not sit in history — going "back" to it + // would re-mint a code and bounce the user around. + // + // REFERRER/URL boundary: the one-time code rides in MyVote's callback URL. cos72's job + // ends here, and using replace() (not assign()) already keeps the code-bearing URL out of + // THIS origin's history. Scrubbing the code from the *landed* MyVote callback URL (it must + // not linger in the address bar, history, or an onward Referer) is MyVote's callback + // responsibility — MyVote #4 does `history.replaceState` to strip `?code=` after exchange. + window.location.replace(target.toString()); + } catch (error: unknown) { + const err = error as { + response?: { status?: number; data?: { message?: string | string[] } }; + message?: string; + }; + const status = err.response?.status; + const raw2 = err.response?.data?.message; + const detail = Array.isArray(raw2) ? raw2.join("; ") : raw2; + + if (status === undefined) { + setPhase({ status: "error", kind: "network", detail: err.message }); + } else if (status === 401) { + setPhase({ status: "error", kind: "unauthorized", detail }); + } else if (status === 400 && detail && WHITELIST_HINT.test(detail)) { + setPhase({ status: "error", kind: "notWhitelisted", detail }); + } else if (status === 400 && detail && NO_ACCOUNT_HINT.test(detail)) { + setPhase({ status: "error", kind: "noAccount", detail }); + } else { + setPhase({ status: "error", kind: "unknown", detail: detail ?? err.message }); + } + } + }, [router]); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + void start(); + }, [start]); + + if (phase.status === "working") { + return ( + +
+
+
+

+ {t("ssoStart.title")} +

+

{t("ssoStart.working")}

+ {redirectUri && ( +

+ {redirectUri} +

+ )} +
+
+ + ); + } + + const isWhitelist = phase.kind === "notWhitelisted"; + const message = isWhitelist + ? t("ssoStart.error.notWhitelisted", { uri: redirectUri }) + : t(`ssoStart.error.${phase.kind}`); + + return ( + +
+
+

+ {t("ssoStart.error.title")} +

+

{message}

+ + {isWhitelist && ( +

+ {t("ssoStart.error.opsHint")} +

+ )} + + {phase.detail && ( +

+ {t("ssoStart.error.detailLabel")}: {phase.detail} +

+ )} + +
+ {phase.kind === "unauthorized" && ( + + {t("ssoStart.signInAgain")} + + )} + {phase.kind === "noAccount" && ( + + {t("ssoStart.createAccount")} + + )} + + + {t("ssoStart.home")} + +
+
+
+
+ ); +} diff --git a/aastar-frontend/components/nav/CommunityNav.tsx b/aastar-frontend/components/nav/CommunityNav.tsx index 2142a45..936ea9d 100644 --- a/aastar-frontend/components/nav/CommunityNav.tsx +++ b/aastar-frontend/components/nav/CommunityNav.tsx @@ -27,13 +27,29 @@ type Tab = { ownerOnly?: boolean; /** Module not built yet — render disabled. */ soon?: boolean; + /** Leaves cos72 (deployed elsewhere) — rendered as a plain , not a next/link route. */ + external?: boolean; }; +/** + * MyVote deployment (MV-7). Optional: when unset, the MyVote tab stays a disabled "即将上线" + * placeholder and no governance entry links out — nothing to click, nothing to 404. + * NEXT_PUBLIC_* is inlined at build time, so this must be a literal property access. + */ +const MYVOTE_URL = process.env.NEXT_PUBLIC_MYVOTE_URL?.trim() || ""; + +/** + * MyVote's own sign-in bounces the browser back to cos72 `/sso/start?redirect_uri=…` + * (see app/sso/start/page.tsx), so we just link at MyVote's root — cos72 never constructs + * MyVote's callback URL, and the redirect_uri whitelist stays owned by the backend. + */ const TABS: Tab[] = [ { key: "overview", label: "概览", href: "#" }, { key: "mytask", label: "MyTask", href: "#", soon: true }, { key: "myshop", label: "MyShop", href: "#", soon: true }, - { key: "myvote", label: "MyVote", href: "#", soon: true }, + MYVOTE_URL + ? { key: "myvote", label: "MyVote · 治理", href: MYVOTE_URL, external: true } + : { key: "myvote", label: "MyVote", href: "#", soon: true }, { key: "members", label: "成员", href: "#" }, { key: "governance", label: "治理", href: "#", ownerOnly: true }, { key: "issue", label: "发币", href: "#", ownerOnly: true }, @@ -84,16 +100,28 @@ export function CommunityNav({ active }: { active?: string }) { ); } + const linkClass = `${base} ${ + isActive + ? "border-b-2 border-emerald-500 font-medium text-gray-900 dark:text-white" + : "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white" + }`; + if (t.external) { + // Same-tab navigation is deliberate (no target="_blank"): governance is a primary + // workflow, not a side trip, and MyVote's SSO round-trip lands the user back on + // cos72 anyway. rel="noopener noreferrer" is kept as a belt-and-braces safe default + // (it also suppresses the Referer to MyVote); the lint rule that pairs + // noopener with _blank does not apply to a same-tab link. Safe exception. + return ( +
  • + + {t.label} + +
  • + ); + } return (
  • - + {t.label}
  • diff --git a/aastar-frontend/env.local.template b/aastar-frontend/env.local.template index 4c4e3f8..eee6359 100644 --- a/aastar-frontend/env.local.template +++ b/aastar-frontend/env.local.template @@ -6,6 +6,12 @@ NEXT_PUBLIC_KMS_API_KEY= NEXT_PUBLIC_BLS_SEED_NODE=https://v1.aastar.io NEXT_PUBLIC_CHAIN_ID=11155111 +# MyVote deployment (MV-7). Optional — leave empty and the community nav shows no MyVote/治理 +# entry. The SSO handoff page (/sso/start) does NOT use this: MyVote itself sends the browser +# to cos72 with ?redirect_uri=/sso/callback, and the backend validates that value +# against SSO_ALLOWED_REDIRECTS (see aastar/.env.example). +NEXT_PUBLIC_MYVOTE_URL= + # AAStar canonical contract addresses (Sepolia 11155111) NEXT_PUBLIC_REGISTRY_ADDRESS=0x7Ba70C5bFDb3A4d0cBd220534f3BE177fefc1788 NEXT_PUBLIC_GTOKEN_ADDRESS=0x9ceDeC089921652D050819ca5BE53765fc05aa9E diff --git a/aastar-frontend/lib/api.ts b/aastar-frontend/lib/api.ts index 0539385..b0ab310 100644 --- a/aastar-frontend/lib/api.ts +++ b/aastar-frontend/lib/api.ts @@ -1,5 +1,17 @@ import axios from "axios"; +declare module "axios" { + export interface AxiosRequestConfig { + /** + * Opt this request out of the global "401 → wipe session + bounce to /auth/login" + * response interceptor, so the caller can handle the 401 itself. Used by flows that + * must keep their own context across the login round-trip (e.g. the SSO handoff page, + * which has to preserve `?redirect_uri`). + */ + skipAuthRedirect?: boolean; + } +} + const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api/v1"; const api = axios.create({ @@ -22,7 +34,7 @@ api.interceptors.request.use(config => { api.interceptors.response.use( response => response, error => { - if (error.response?.status === 401) { + if (error.response?.status === 401 && !error.config?.skipAuthRedirect) { localStorage.removeItem("token"); localStorage.removeItem("user"); window.location.href = "/auth/login"; @@ -31,6 +43,23 @@ api.interceptors.response.use( } ); +// MyVote SSO handoff (MV-7 frontend half of the MV-1 backend endpoints). +// `/sso/authorize` is JWT-guarded: the cos72 session token identifies the user (the userId is +// NEVER sent in the body) and the backend mints a one-time code bound to a *whitelisted* +// redirect_uri (fail-closed against SSO_ALLOWED_REDIRECTS). The response echoes the normalized +// redirectUri — the browser must redirect to THAT value, not to the raw URL query param, which +// is what keeps this from being an open redirect. +// `skipAuthRedirect`: a 401 here must not silently dump the user on a bare /auth/login — the +// SSO start page re-enters login with a return path so the handoff can resume. +export const ssoAPI = { + authorize: (redirectUri: string) => + api.post<{ code: string; redirectUri: string }>( + "/sso/authorize", + { redirect_uri: redirectUri }, + { skipAuthRedirect: true } + ), +}; + // Generic gasless UserOperation API (Phase 0 §0.2 — the backend contract cosSend calls). // Two-phase like transferAPI: `prepare` returns the userOpHash for the browser to sign // via passkey (WebAuthn assertion whose challenge = userOpHash); `submit` hands the diff --git a/aastar-frontend/lib/i18n/locales/en.json b/aastar-frontend/lib/i18n/locales/en.json index d3dea16..cbaffdb 100644 --- a/aastar-frontend/lib/i18n/locales/en.json +++ b/aastar-frontend/lib/i18n/locales/en.json @@ -1082,5 +1082,25 @@ "done": "Tier limits raised", "cancel": "Cancel", "note": "Guardians sign the change digest off-chain; you submit it gaslessly with your passkey. Quorum = the contract's RECOVERY_THRESHOLD." + }, + "ssoStart": { + "title": "Signing you in to MyVote", + "working": "Authorizing the handoff — you'll be redirected automatically.", + "back": "Back", + "home": "Go to dashboard", + "signInAgain": "Sign in again", + "createAccount": "Create your account", + "error": { + "title": "SSO handoff failed", + "detailLabel": "Backend response", + "missingRedirect": "This page must be opened with a ?redirect_uri parameter. Start from MyVote's sign-in button instead of opening this URL directly.", + "notWhitelisted": "cos72 refused to authorize this handoff: the redirect_uri \"{{uri}}\" is not registered in the backend SSO_ALLOWED_REDIRECTS whitelist.", + "opsHint": "For operators: add this exact redirect_uri (origin + callback path) to SSO_ALLOWED_REDIRECTS in the cos72 backend environment, then restart the API. An empty/unset SSO_ALLOWED_REDIRECTS rejects every redirect_uri by design (fail-closed).", + "noAccount": "Your cos72 account has no AirAccount yet. Create your smart account first, then sign in to MyVote.", + "unauthorized": "Your cos72 session is missing or has expired. Sign in again to continue to MyVote.", + "badRedirect": "The backend returned a redirect target that is not a valid URL. Nothing was redirected — report this to the cos72 team.", + "network": "Could not reach the cos72 backend. Check your connection and try again.", + "unknown": "The cos72 backend rejected the SSO authorization request." + } } } diff --git a/aastar-frontend/lib/i18n/locales/zh.json b/aastar-frontend/lib/i18n/locales/zh.json index e4b34c1..c18271b 100644 --- a/aastar-frontend/lib/i18n/locales/zh.json +++ b/aastar-frontend/lib/i18n/locales/zh.json @@ -1082,5 +1082,25 @@ "done": "限额已提高", "cancel": "取消", "note": "guardian 链下签变更 digest,你用 passkey 免 gas 提交。法定数 = 合约 RECOVERY_THRESHOLD。" + }, + "ssoStart": { + "title": "正在为你登录 MyVote", + "working": "正在授权跳转——稍候将自动跳转。", + "back": "返回", + "home": "回到控制台", + "signInAgain": "重新登录", + "createAccount": "去创建账户", + "error": { + "title": "SSO 跳转失败", + "detailLabel": "后端返回", + "missingRedirect": "本页需要携带 ?redirect_uri 参数打开。请从 MyVote 的登录按钮进入,而不要直接访问本 URL。", + "notWhitelisted": "cos72 拒绝授权本次跳转:redirect_uri「{{uri}}」未登记在后端 SSO_ALLOWED_REDIRECTS 白名单中。", + "opsHint": "运维提示:请把这个完整的 redirect_uri(origin + 回调路径)加入 cos72 后端环境变量 SSO_ALLOWED_REDIRECTS,然后重启 API。SSO_ALLOWED_REDIRECTS 为空或未设置时,后端会拒绝所有 redirect_uri(fail-closed,属预期行为)。", + "noAccount": "你的 cos72 账户还没有 AirAccount。请先创建智能账户,再登录 MyVote。", + "unauthorized": "cos72 登录态缺失或已过期。请重新登录后继续前往 MyVote。", + "badRedirect": "后端返回的跳转地址不是合法 URL,已中止跳转。请联系 cos72 团队。", + "network": "无法连接 cos72 后端。请检查网络后重试。", + "unknown": "cos72 后端拒绝了本次 SSO 授权请求。" + } } } diff --git a/aastar-frontend/lib/safe-return-path.test.ts b/aastar-frontend/lib/safe-return-path.test.ts new file mode 100644 index 0000000..3a8a364 --- /dev/null +++ b/aastar-frontend/lib/safe-return-path.test.ts @@ -0,0 +1,79 @@ +/** + * Unit tests for {@link safeReturnPath} — the open-redirect guard for `?redirect=` login + * round-trips (MV-7). Run with the built-in Node test runner via ts-node: + * + * npm run test:unit -w aastar-frontend + * + * These are the exact bypass payloads Codex flagged as Critical (control-char authority + * smuggling: a value that passes a naive prefix check but the WHATWG URL parser strips/folds + * into an off-origin authority), plus the plain protocol-relative / absolute forms and the + * legitimate same-origin paths that must still be honored. + * + * NOTE: `.ts` import extension + node:test means this file is intentionally EXCLUDED from the + * app's `tsconfig.json` (see its `exclude`) so `tsc --noEmit` / `next build` don't try to + * compile it under `moduleResolution: bundler`. ts-node handles it at test time. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { safeReturnPath } from "./safe-return-path.ts"; + +const ORIGIN = "https://cos72.example"; + +test("safeReturnPath rejects open-redirect payloads", () => { + const rejected: Array<[string, string]> = [ + ["//evil.com", "protocol-relative authority"], + ["/\\evil.com", "backslash-folded authority"], + ["/%2f%2fevil.com", "URLSearchParams decodes to /\\evil... already; here raw //"], + ["%2F%2Fevil.com", "no leading slash after decode"], + ["/\t//evil.com", "leading-tab smuggle (%09 decoded) → parser strips tab"], + ["/\r\n//evil.com", "CR/LF smuggle (%0d%0a decoded) → parser strips CR/LF"], + ["https://evil.com", "absolute URL, different origin"], + ["http://cos72.example.evil.com/", "look-alike host"], + ["\\\\evil.com", "backslash authority"], + [" /dashboard", "leading space"], + ]; + for (const [payload, why] of rejected) { + assert.equal( + safeReturnPath(payload, ORIGIN), + null, + `should reject: ${why} (${JSON.stringify(payload)})` + ); + } +}); + +test("safeReturnPath handles the raw percent-encoded bypass strings after decode", () => { + // What the browser hands us after URLSearchParams.get() decodes the query. `/%09//evil.com` + // → `/\t//evil.com`; `/%0d%0a//evil.com` → `/\r\n//evil.com`. Both must be null. + assert.equal(safeReturnPath("/\t//evil.com", ORIGIN), null); + assert.equal(safeReturnPath("/\r\n//evil.com", ORIGIN), null); +}); + +test("safeReturnPath rejects dot-segment normalization that re-introduces an authority", () => { + // The WHATWG URL parser folds `..` dot-segments, so an input that starts with a single "/" + // can normalize to a pathname of `//evil.com` (a protocol-relative authority) while + // url.origin still equals base (the host lives in the path). The (3b) final-form recheck + // must catch these. `%2e%2e` = `..`; `%252e%252e` is double-encoded `..`. + const rejected = ["/%2e%2e//evil.com", "/a/..//evil.com", "/foo/../..//evil.com"]; + for (const payload of rejected) { + assert.equal(safeReturnPath(payload, ORIGIN), null, `should reject dot-segment: ${payload}`); + } + // Double-encoded `%252e%252e` is NOT a real bypass at the router.push sink: the WHATWG URL + // parser does not decode `%25`, so the pathname stays `/%252e%252e//evil.com` (same-origin, + // single leading slash) and router.push navigates same-origin without a second decode. It is + // therefore honored as a same-origin path rather than rejected. + assert.equal(safeReturnPath("/%252e%252e//evil.com", ORIGIN), "/%252e%252e//evil.com"); +}); + +test("safeReturnPath honors legitimate same-origin paths", () => { + assert.equal(safeReturnPath("/dashboard", ORIGIN), "/dashboard"); + assert.equal(safeReturnPath("/sso/start?redirect_uri=x", ORIGIN), "/sso/start?redirect_uri=x"); + assert.equal(safeReturnPath("/proposal/0xabc#frag", ORIGIN), "/proposal/0xabc#frag"); + // In-origin `..` that stays rooted must NOT be over-rejected: `/a/../b` normalizes to `/b`. + assert.equal(safeReturnPath("/a/../b", ORIGIN), "/b"); +}); + +test("safeReturnPath returns null for empty / nullish input", () => { + assert.equal(safeReturnPath(null, ORIGIN), null); + assert.equal(safeReturnPath(undefined, ORIGIN), null); + assert.equal(safeReturnPath("", ORIGIN), null); +}); diff --git a/aastar-frontend/lib/safe-return-path.ts b/aastar-frontend/lib/safe-return-path.ts new file mode 100644 index 0000000..5a10a85 --- /dev/null +++ b/aastar-frontend/lib/safe-return-path.ts @@ -0,0 +1,68 @@ +/** + * Same-origin return-path guard for `?redirect=` login round-trips. + * + * Flows that bounce through /auth/login (e.g. /sso/start) pass the URL to resume on as a + * `?redirect=` query param. After sign-in the login page does `router.push(returnTo)` — so + * `returnTo` is a navigation SINK and MUST be proven same-origin, or `?redirect=` becomes an + * open redirect (phishing: land the freshly-authenticated user on an attacker origin). + * + * A naive "starts with '/' and not '//'" check is NOT enough. `URLSearchParams.get()` decodes + * percent-encoding first, so `?redirect=/%09//evil.com` arrives here as `/\t//evil.com`, and + * `?redirect=/%0d%0a//evil.com` as `/\r\n//evil.com`. Both pass a naive prefix check, yet the + * WHATWG URL parser STRIPS leading tab/CR/LF and then reads `//evil.com` as a + * protocol-relative authority → `https://evil.com/`. Backslash is likewise folded to `/` by + * many stacks. Defense here is layered: + * + * 1. reject any C0 control char or space (codepoint <= 0x20) and any backslash — these are + * exactly the characters URL parsers strip/fold to smuggle in an authority; + * 2. require a single leading "/" (reject "//…", the protocol-relative form), and reject + * percent-encoded path separators (`%2f`/`%5c`) that a downstream decoder could fold into + * an authority; + * 3. AUTHORITATIVE: resolve against the real origin with the WHATWG URL parser and demand + * `url.origin === origin`. Whatever survives (1)+(2) is normalized here, and a value that + * resolves off-origin is rejected. The returned string is the parser's own + * pathname+search+hash — never the raw input — so nothing un-normalized reaches the sink. + * + * @param raw the decoded `?redirect=` value (or null) + * @param origin same-origin to enforce against; defaults to `window.location.origin`. Passed + * explicitly by tests (Node has no `window`); callers in the browser omit it. + * @returns a normalized same-origin `path+search+hash`, or null if `raw` is unusable. + */ +export function safeReturnPath(raw: string | null | undefined, origin?: string): string | null { + if (!raw) return null; + + // (1) No control chars / space / backslash — the smuggling alphabet for authority injection. + for (let i = 0; i < raw.length; i++) { + const code = raw.charCodeAt(i); + if (code <= 0x20 || code === 0x5c /* \ */) return null; + } + + // (2) Must be a rooted path, not a protocol-relative `//host` authority. + if (!raw.startsWith("/") || raw.startsWith("//")) return null; + + // (2b) Reject percent-encoded path separators (`%2f` = /, `%5c` = \). The WHATWG URL parser + // leaves them encoded, so `/%2f%2fevil.com` stays same-origin HERE — but a downstream router + // or proxy that decodes the path first would fold it to `//evil.com` (an authority). Same + // fail-closed stance the backend takes in resolveRedirect's ENCODED_PATH_SEPARATOR; a real + // return path never needs an encoded separator. + if (/%2f|%5c/i.test(raw)) return null; + + // (3) Authoritative same-origin check via the WHATWG URL parser. + const base = origin ?? (typeof window !== "undefined" ? window.location.origin : undefined); + if (!base) return null; + try { + const url = new URL(raw, base); + if (url.origin !== base) return null; + const result = url.pathname + url.search + url.hash; + // (3b) Final-form recheck, independent of the input check in (2). The parser folds + // dot-segments (`..`), so an input that started with a single "/" — e.g. `/%2e%2e//evil.com` + // (%2e%2e = ..), `/a/..//evil.com`, `/foo/../..//evil.com` — can normalize to a pathname of + // `//evil.com`, which `router.push` would read as a protocol-relative authority. url.origin + // still equals `base` (the host lives in the path, not the authority), so (3) passes; guard + // the normalized OUTPUT here. + if (!result.startsWith("/") || result.startsWith("//")) return null; + return result; + } catch { + return null; + } +} diff --git a/aastar-frontend/package.json b/aastar-frontend/package.json index fd7bb69..004b297 100644 --- a/aastar-frontend/package.json +++ b/aastar-frontend/package.json @@ -15,6 +15,7 @@ "i18n:check": "node scripts/check-i18n-parity.mjs", "test": "echo \"⚠️ No tests yet - skipping\"", "test:ci": "echo \"⚠️ No tests yet - skipping\"", + "test:unit": "node --no-warnings --import ts-node/esm --test \"lib/**/*.test.ts\"", "test:e2e:ui": "playwright test" }, "dependencies": { diff --git a/aastar-frontend/tsconfig.json b/aastar-frontend/tsconfig.json index dcab4d1..3e481fb 100644 --- a/aastar-frontend/tsconfig.json +++ b/aastar-frontend/tsconfig.json @@ -29,5 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "**/*.test.ts"] }