From db65e2c66b76d8586388427e7b4a7b8bd975dcf4 Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Fri, 23 Oct 2026 08:02:21 +0100 Subject: [PATCH 1/4] fix(middleware): reject open redirect targets in redirect param Validate that the redirect query parameter is a same-origin relative path before using it as a post-login destination. Absolute URLs, protocol-relative URLs, and backslash variants now fall back to /dashboard so authenticated users cannot be phished via /connect. Closes #541 --- src/middleware.test.ts | 53 ++++++++++++++++++++++++++++++++++++++++++ src/middleware.ts | 16 ++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 1195f07..5a3a907 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -137,6 +137,59 @@ describe("auth routes", () => { }); }); +describe("open redirect protection (#541)", () => { + it("rejects an absolute https URL in the redirect parameter", () => { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", "?redirect=https://evil.com")) + ); + + expect(target?.pathname).toBe("/dashboard"); + expect(target?.hostname).not.toBe("evil.com"); + }); + + it("rejects an absolute http URL with a path", () => { + const target = redirectTarget( + middleware( + makeRequest("/connect", "jwt-abc", "?redirect=http://evil.com/phish") + ) + ); + + expect(target?.pathname).toBe("/dashboard"); + }); + + it("rejects a protocol-relative URL", () => { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", "?redirect=//evil.com")) + ); + + expect(target?.pathname).toBe("/dashboard"); + }); + + it("rejects a backslash-based open redirect", () => { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", "?redirect=/%5Cevil.com")) + ); + + expect(target?.pathname).toBe("/dashboard"); + }); + + it("still honours a safe relative path", () => { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", "?redirect=/courses/course-1/quiz")) + ); + + expect(target?.pathname).toBe("/courses/course-1/quiz"); + }); + + it("falls back to /dashboard when redirect is empty", () => { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", "?redirect=")) + ); + + expect(target?.pathname).toBe("/dashboard"); + }); +}); + describe("matcher", () => { it("covers every protected area and both auth routes", () => { expect(config.matcher).toEqual( diff --git a/src/middleware.ts b/src/middleware.ts index d8794f8..f7edd06 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -32,6 +32,20 @@ function isAuthRoute(pathname: string): boolean { return AUTH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } +/** + * Reject open-redirect targets (#541). Only same-origin relative paths are + * allowed: must start with a single "/", must not be protocol-relative + * ("//evil.com"), and must not embed a scheme ("https://evil.com"). + */ +function isSafeRedirectPath(redirect: string | null): redirect is string { + if (!redirect) return false; + if (!redirect.startsWith("/")) return false; + if (redirect.startsWith("//")) return false; + if (redirect.includes("://")) return false; + if (redirect.includes("\\")) return false; + return true; +} + /** * Decode JWT payload and check expiry (#495). * Uses atob() for base64 decoding — works in Edge runtime. @@ -71,7 +85,7 @@ export function middleware(request: NextRequest) { if (sessionToken && !isJwtExpired(sessionToken)) { const redirect = request.nextUrl.searchParams.get("redirect"); const url = request.nextUrl.clone(); - url.pathname = redirect || "/dashboard"; + url.pathname = isSafeRedirectPath(redirect) ? redirect : "/dashboard"; url.searchParams.delete("redirect"); return NextResponse.redirect(url); } From 906f3883d9820530cd7f79953725efe61d76d731 Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Fri, 23 Oct 2026 08:03:08 +0100 Subject: [PATCH 2/4] fix(api): hash JWT before using it as a cache key component Cache keys previously embedded the raw JWT, so tokens could surface in heap dumps and memory snapshots. Replace the token with a 64-bit non-cryptographic digest so keying stays deterministic per token but the credential itself is no longer retained in cache key strings. Closes #542 --- src/lib/api/client.test.ts | 37 ++++++++++++++++++++++++++++++++++++- src/lib/api/client.ts | 19 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts index 53a8fb1..5631be1 100644 --- a/src/lib/api/client.test.ts +++ b/src/lib/api/client.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient, isAbortError } from "./client"; +import { apiClient, hashJwtForCacheKey, isAbortError } from "./client"; import { useErrorStore } from "@/store/error-store"; // zustand's persist middleware writes on every set(); jsdom's localStorage is @@ -447,3 +447,38 @@ describe("apiClient cache behaviour (issue #312)", () => { }); }); +// ── Issue #542: JWT must not appear in cache keys ──────────────────────────── + +describe("apiClient cache keys (issue #542)", () => { + const sampleJwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEif0.signature"; + + it("does not embed the raw JWT in the hashed cache key component", () => { + const hashed = hashJwtForCacheKey(sampleJwt); + + expect(hashed).not.toContain(sampleJwt); + expect(hashed).not.toContain("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"); + expect(hashed).not.toContain("signature"); + }); + + it("hashes deterministically so cache hit/miss behaviour is unchanged", () => { + expect(hashJwtForCacheKey(sampleJwt)).toBe(hashJwtForCacheKey(sampleJwt)); + }); + + it("distinguishes different tokens", () => { + expect(hashJwtForCacheKey("token-a")).not.toBe(hashJwtForCacheKey("token-b")); + }); + + it("serves a cache hit for the same token and a miss for a different one", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: "user-1" })); + await apiClient.get("/cache-jwt", "jwt-user-1"); + await apiClient.get("/cache-jwt", "jwt-user-1"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + fetchMock.mockResolvedValueOnce(jsonResponse({ data: "user-2" })); + const other = await apiClient.get("/cache-jwt", "jwt-user-2"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(other).toEqual({ data: "user-2" }); + }); +}); + diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index bdcb427..73740fd 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -74,6 +74,23 @@ type CacheEntry = { expiresAt: number; }; +/** + * Fast non-cryptographic digest of a JWT for cache keying (#542). + * Two independent FNV-1a streams yield a 64-bit hex digest so the raw + * token never appears in heap-dumpable cache key strings, while cache + * hit/miss behaviour stays deterministic per token. + */ +export function hashJwtForCacheKey(jwt: string): string { + let h1 = 0x811c9dc5; + let h2 = 0x01000193; + for (let i = 0; i < jwt.length; i++) { + const c = jwt.charCodeAt(i); + h1 = Math.imul(h1 ^ c, 0x01000193); + h2 = Math.imul(h2 ^ c, 0x85ebca6b); + } + return `${(h1 >>> 0).toString(16)}${(h2 >>> 0).toString(16)}`; +} + const responseCache = new Map(); /** @@ -374,7 +391,7 @@ class ApiClient { } private cacheKey(url: string, jwt?: string): string { - return jwt ? `${url}|${jwt}` : url; + return jwt ? `${url}|${hashJwtForCacheKey(jwt)}` : url; } /** From eb2754b6c5c8b54fa69fdc13f4e143753873d42f Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Fri, 23 Oct 2026 08:03:28 +0100 Subject: [PATCH 3/4] feat(security): add HSTS, COOP, COEP, and CORP response headers Ship Strict-Transport-Security in production and isolate the browsing context with Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, and Cross-Origin-Resource-Policy so first-visit MITM and cross-origin process isolation gaps are closed. Closes #544 --- next.config.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/next.config.mjs b/next.config.mjs index f2b14ac..7796fb7 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -78,6 +78,27 @@ const securityHeaders = [ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()", }, + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "Cross-Origin-Embedder-Policy", + value: "credentialless", + }, + { + key: "Cross-Origin-Resource-Policy", + value: "same-origin", + }, + // HSTS only in production so local HTTP development keeps working. + ...(isDev + ? [] + : [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + ]), ]; const nextConfig = { From cc7ec1bcc99789262873350d91ac896eb27509d9 Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Fri, 23 Oct 2026 08:03:57 +0100 Subject: [PATCH 4/4] fix(wallet): show install link for the selected wallet in error banner The error banner always linked to Freighter even when the user had chosen LOBSTR or Rabet. Resolve the selected provider and link to that wallet's install page, falling back to a generic list of Freighter, LOBSTR, and Rabet when the provider is unknown. Closes #540 --- src/components/shared/wallet-error-banner.tsx | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/components/shared/wallet-error-banner.tsx b/src/components/shared/wallet-error-banner.tsx index a5a78bf..86cec42 100644 --- a/src/components/shared/wallet-error-banner.tsx +++ b/src/components/shared/wallet-error-banner.tsx @@ -1,7 +1,9 @@ "use client"; +import { useEffect, useState } from "react"; import { AlertTriangle, X, ExternalLink } from "lucide-react"; import type { WalletError } from "@/lib/hooks/use-auth"; +import { getSelectedWalletId, type WalletProviderId } from "@/lib/stellar/wallet"; interface WalletErrorBannerProps { error: WalletError; @@ -9,11 +11,41 @@ interface WalletErrorBannerProps { onRetry?: () => void; } +const WALLET_INSTALL_LINKS: Record< + WalletProviderId, + { name: string; url: string } +> = { + freighter: { name: "Freighter", url: "https://www.freighter.app/" }, + lobstr: { name: "LOBSTR", url: "https://lobstr.co/" }, + rabet: { name: "Rabet", url: "https://rabet.io/" }, +}; + +const ALL_WALLETS = Object.values(WALLET_INSTALL_LINKS); + +function resolveInstallLinks( + walletId: string | null +): Array<{ name: string; url: string }> { + if (walletId && walletId in WALLET_INSTALL_LINKS) { + return [ + WALLET_INSTALL_LINKS[walletId as WalletProviderId], + ]; + } + return ALL_WALLETS; +} + export function WalletErrorBanner({ error, onDismiss, onRetry, }: WalletErrorBannerProps) { + const [walletId, setWalletId] = useState(null); + + useEffect(() => { + setWalletId(getSelectedWalletId()); + }, []); + + const installLinks = resolveInstallLinks(walletId); + return (
{error.message}

{error.resolution}

{error.type === "not_installed" && ( - - Get Freighter - - +
+ {installLinks.map((wallet) => ( + + Get {wallet.name} + + + ))} +
)} {onRetry && (