diff --git a/next.config.mjs b/next.config.mjs index f2b14ac..65729ee 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,8 +7,8 @@ const withBundleAnalyzer = bundleAnalyzer({ /** * CSP sources for Freighter wallet messaging and Stellar network/CDN resources. - * Freighter communicates via window.postMessage; chrome-extension: covers - * residual extension fetches. Horizon + Soroban RPC cover on-chain calls. + * Wallet extension origins are explicitly scoped. Horizon + Soroban RPC cover + * on-chain calls. */ function resolveApiOrigin() { try { @@ -23,6 +23,11 @@ function resolveApiOrigin() { const isDev = process.env.NODE_ENV !== "production"; const apiOrigin = resolveApiOrigin(); +const walletExtensionOrigins = [ + "chrome-extension://bcacfldlkkdogcmkkibnjlakofdplcbk", + "chrome-extension://ldiagbjmlmjiieclmdkagofdjcgodjle", + "chrome-extension://hgmoaheomcjnaheggkfafnjilfcefbmo", +]; const ContentSecurityPolicy = [ "default-src 'self'", @@ -45,9 +50,9 @@ const ContentSecurityPolicy = [ "https://*.stellar.gateway.fm", "https://stellar.expert", "https://*.stellar.expert", - "chrome-extension:", + ...walletExtensionOrigins, ].join(" "), - "frame-src 'self' chrome-extension:", + ["frame-src 'self'", ...walletExtensionOrigins].join(" "), "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", diff --git a/src/app/courses/[courseId]/layout.tsx b/src/app/courses/[courseId]/layout.tsx index 54aabe0..e1f635d 100644 --- a/src/app/courses/[courseId]/layout.tsx +++ b/src/app/courses/[courseId]/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { encodePathParam } from "@/lib/api/path"; import { apiClient, requireApiData } from "@/lib/api/client"; import type { Course } from "@/types/course"; diff --git a/src/app/courses/[courseId]/opengraph-image.tsx b/src/app/courses/[courseId]/opengraph-image.tsx index 4973ad2..be8b5db 100644 --- a/src/app/courses/[courseId]/opengraph-image.tsx +++ b/src/app/courses/[courseId]/opengraph-image.tsx @@ -1,4 +1,5 @@ import { createOgImage, OG_SIZE } from "@/lib/utils/og-image"; +import { encodePathParam } from "@/lib/api/path"; import type { Course } from "@/types/course"; export const alt = "ChainLearn course"; @@ -18,9 +19,12 @@ export default async function Image({ params }: ImageProps) { "Earn tokens and verifiable credentials while learning on Stellar."; try { - const response = await fetch(`${API_URL}/courses/${courseId}`, { - next: { revalidate: 3600 }, - }); + const response = await fetch( + `${API_URL}/courses/${encodePathParam(courseId, "courseId")}`, + { + next: { revalidate: 3600 }, + } + ); if (response.ok) { const payload = (await response.json()) as { data?: Course } & Course; const course = payload.data ?? payload; diff --git a/src/app/credentials/[credentialId]/layout.tsx b/src/app/credentials/[credentialId]/layout.tsx index 0074cae..014f31b 100644 --- a/src/app/credentials/[credentialId]/layout.tsx +++ b/src/app/credentials/[credentialId]/layout.tsx @@ -11,7 +11,7 @@ export async function generateMetadata({ params }: PageProps): Promise try { const response = await apiClient.get( - `/credentials/${credentialId}` + `/credentials/${encodePathParam(credentialId, "credentialId")}` ); const credential = requireApiData(response.data); const title = `${credential.courseTitle} - Credential`; diff --git a/src/app/credentials/[credentialId]/opengraph-image.tsx b/src/app/credentials/[credentialId]/opengraph-image.tsx index 5397fc6..e951641 100644 --- a/src/app/credentials/[credentialId]/opengraph-image.tsx +++ b/src/app/credentials/[credentialId]/opengraph-image.tsx @@ -1,4 +1,5 @@ import { createOgImage, OG_SIZE } from "@/lib/utils/og-image"; +import { encodePathParam } from "@/lib/api/path"; import type { CredentialNFT } from "@/types/stellar"; export const alt = "ChainLearn credential"; @@ -17,9 +18,10 @@ export default async function Image({ params }: ImageProps) { let description = "On-chain verifiable credential issued on the Stellar network."; try { - const response = await fetch(`${API_URL}/credentials/${credentialId}`, { - next: { revalidate: 3600 }, - }); + const response = await fetch( + `${API_URL}/credentials/${encodePathParam(credentialId, "credentialId")}`, + { next: { revalidate: 3600 } } + ); if (response.ok) { const payload = (await response.json()) as { data?: CredentialNFT; diff --git a/src/app/verify/[credentialId]/opengraph-image.tsx b/src/app/verify/[credentialId]/opengraph-image.tsx index f989503..3c3c37c 100644 --- a/src/app/verify/[credentialId]/opengraph-image.tsx +++ b/src/app/verify/[credentialId]/opengraph-image.tsx @@ -1,4 +1,5 @@ import { createOgImage, OG_SIZE } from "@/lib/utils/og-image"; +import { encodePathParam } from "@/lib/api/path"; export const alt = "ChainLearn credential verification"; export const size = OG_SIZE; @@ -18,7 +19,7 @@ export default async function Image({ params }: ImageProps) { try { const response = await fetch( - `${API_URL}/credentials/${credentialId}/verify`, + `${API_URL}/credentials/${encodePathParam(credentialId, "credentialId")}/verify`, { next: { revalidate: 3600 } } ); if (response.ok) { diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 92f362a..46533dd 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -193,7 +193,7 @@ export async function revokeSession( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.delete<{ success: boolean }>( - `/auth/sessions/${sessionId}`, + `/auth/sessions/${encodePathParam(sessionId, "sessionId")}`, token, signal ); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index bdcb427..a854de3 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,7 +1,10 @@ -import { ApiError, ApiResponse } from "@/types/api"; +import { ApiError } from "@/types/api"; +import type { ApiResponse, ResponseInterceptor } from "@/types/api"; import { useAuthStore } from "@/store/auth-store"; import { useErrorStore } from "@/store/error-store"; +export type { ResponseInterceptor } from "@/types/api"; + /** * AbortController support (#315): * - signal parameter on all methods (get, post, put, delete) diff --git a/src/lib/api/courses.ts b/src/lib/api/courses.ts index 968b4d1..25e2712 100644 --- a/src/lib/api/courses.ts +++ b/src/lib/api/courses.ts @@ -92,7 +92,7 @@ export async function enrollInCourse( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.post( - `/courses/${courseId}/enroll`, + `/courses/${encodePathParam(courseId, "courseId")}/enroll`, {}, token, signal @@ -118,7 +118,7 @@ export async function getModule( signal?: AbortSignal ): Promise { const response = await apiClient.get( - `/courses/${courseId}/modules/${moduleId}`, + `/courses/${encodePathParam(courseId, "courseId")}/modules/${encodePathParam(moduleId, "moduleId")}`, jwt, signal ); @@ -147,7 +147,7 @@ export async function markModuleComplete( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.post( - `/courses/${courseId}/modules/${moduleId}/complete`, + `/courses/${encodePathParam(courseId, "courseId")}/modules/${encodePathParam(moduleId, "moduleId")}/complete`, {}, token, signal diff --git a/src/lib/api/credentials.test.ts b/src/lib/api/credentials.test.ts index 627d47f..312ee2c 100644 --- a/src/lib/api/credentials.test.ts +++ b/src/lib/api/credentials.test.ts @@ -100,6 +100,25 @@ describe("verifyCredential", () => { expect(url).toMatch(/\/credentials\/verify\/cred-abc$/); }); + it("encodes credential IDs as one path segment", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: { valid: false }, success: true }) + ); + + await verifyCredential("cred/id?x#fragment"); + + expect(String(fetchMock.mock.calls[0][0])).toContain( + "/credentials/verify/cred%2Fid%3Fx%23fragment" + ); + }); + + it("rejects a blank credential ID before making a request", async () => { + await expect(verifyCredential(" ")).rejects.toThrow( + "credentialId must be a non-empty path identifier" + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("returns { valid: false, error: 'not_found' } on 404 without throwing", async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ message: "Credential not found" }, 404) diff --git a/src/lib/api/credentials.ts b/src/lib/api/credentials.ts index 0b6935b..69afc30 100644 --- a/src/lib/api/credentials.ts +++ b/src/lib/api/credentials.ts @@ -1,5 +1,7 @@ import { apiClient, requireApiData } from "./client"; import { ApiError } from "@/types/api"; +import { getValidToken } from "./auth"; +import { encodePathParam } from "./path"; import type { CredentialNFT } from "@/types/stellar"; /** Reason a public credential verification did not succeed (issue #309). */ @@ -48,7 +50,7 @@ export async function getCredential( signal?: AbortSignal ): Promise { const response = await apiClient.get( - `/credentials/${credentialId}`, + `/credentials/${encodePathParam(credentialId, "credentialId")}`, jwt, signal ); @@ -70,7 +72,7 @@ export async function verifyCredential( ): Promise { try { const response = await apiClient.get( - `/credentials/verify/${credentialId}`, + `/credentials/verify/${encodePathParam(credentialId, "credentialId")}`, undefined, signal ); diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index cec567e..c2cbec3 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -71,5 +71,6 @@ export { isAbortError, createLoggingInterceptor, createAnalyticsInterceptor, - type ResponseInterceptor, } from "./client"; + +export type { ResponseInterceptor } from "@/types/api"; diff --git a/src/lib/api/notifications.ts b/src/lib/api/notifications.ts index 5b7dc66..2e8983b 100644 --- a/src/lib/api/notifications.ts +++ b/src/lib/api/notifications.ts @@ -29,7 +29,12 @@ export async function markNotificationAsRead( ): Promise { const validToken = await getValidToken(); const token = validToken || jwt; - await apiClient.post(`/notifications/${id}/read`, {}, token, signal); + await apiClient.post( + `/notifications/${encodePathParam(id, "notificationId")}/read`, + {}, + token, + signal + ); } /** diff --git a/src/lib/api/path.test.ts b/src/lib/api/path.test.ts new file mode 100644 index 0000000..cf0b0dc --- /dev/null +++ b/src/lib/api/path.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { encodePathParam } from "./path"; + +describe("encodePathParam", () => { + it("encodes reserved URL characters as a single path segment", () => { + expect(encodePathParam("id/part?query#fragment", "id")).toBe( + "id%2Fpart%3Fquery%23fragment" + ); + }); + + it.each([".", ".."])("rejects dot segment %s", (value) => { + expect(() => encodePathParam(value, "id")).toThrow( + "id must be a non-empty path identifier" + ); + }); + + it.each(["", " ", "\t\n"])("rejects blank values", (value) => { + expect(() => encodePathParam(value, "id")).toThrow( + "id must be a non-empty path identifier" + ); + }); +}); \ No newline at end of file diff --git a/src/lib/api/path.ts b/src/lib/api/path.ts new file mode 100644 index 0000000..b2da53c --- /dev/null +++ b/src/lib/api/path.ts @@ -0,0 +1,6 @@ +export function encodePathParam(value: string, name: string): string { + if (!value.trim() || value === "." || value === "..") { + throw new TypeError(`${name} must be a non-empty path identifier.`); + } + return encodeURIComponent(value); +} \ No newline at end of file diff --git a/src/lib/api/quizzes.ts b/src/lib/api/quizzes.ts index 83d33c2..68aab3e 100644 --- a/src/lib/api/quizzes.ts +++ b/src/lib/api/quizzes.ts @@ -35,7 +35,7 @@ export async function getQuiz( signal?: AbortSignal ): Promise { const response = await apiClient.get( - `/courses/${courseId}/quiz`, + `/courses/${encodePathParam(courseId, "courseId")}/quiz`, jwt, signal ); @@ -98,7 +98,7 @@ export async function submitQuiz( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.post( - `/quizzes/${submission.quizId}/submit`, + `/quizzes/${encodePathParam(submission.quizId, "quizId")}/submit`, submission, token, signal @@ -125,7 +125,7 @@ export async function getQuizAttempts( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.get( - `/quizzes/${quizId}/attempts`, + `/quizzes/${encodePathParam(quizId, "quizId")}/attempts`, token, signal ); diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 1195f07..fc605d7 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { config, middleware } from "./middleware"; +import { makeJwt } from "@/tests/helpers/jwt"; + +const ORIGIN = "https://chainlearn.test"; +const VALID_SESSION = makeJwt({ + sub: "GABC123", + exp: Math.floor(Date.now() / 1000) + 3600, +}); /** * Route protection at the Edge. @@ -10,8 +17,6 @@ import { config, middleware } from "./middleware"; * which a signed-out visitor can see dashboard content. */ -const ORIGIN = "https://chainlearn.test"; - /** * Minimal NextRequest stand-in — middleware only reads nextUrl and cookies. * `nextUrl` is a URL plus the `clone()` NextURL adds, which the middleware uses @@ -32,7 +37,7 @@ function makeRequest(pathname: string, sessionToken?: string, search = "") { url: url.toString(), cookies: { get: (name: string) => - name === "chainlearn-session" && sessionToken + name === "chainlearn-session" && sessionToken !== undefined ? { name, value: sessionToken } : undefined, }, @@ -66,8 +71,10 @@ describe("protected routes", () => { expect(target?.pathname).toBe("/connect"); }); - it.each(PROTECTED)("allows %s with a session", (pathname) => { - expect(redirectTarget(middleware(makeRequest(pathname, "jwt-abc")))).toBeNull(); + it.each(PROTECTED)("allows %s with a valid session", (pathname) => { + expect( + redirectTarget(middleware(makeRequest(pathname, VALID_SESSION))) + ).toBeNull(); }); it("preserves the intended destination so login can return there", () => { @@ -81,10 +88,34 @@ describe("protected routes", () => { it("redirects rather than rendering, so no protected markup is sent", () => { const response = middleware(makeRequest("/dashboard")); - // A 3xx means the body was never produced — that is what removes the flash. expect(response.status).toBeGreaterThanOrEqual(300); expect(response.status).toBeLessThan(400); }); + + it("rejects an expired JWT and clears the session cookie", () => { + const expired = makeJwt({ sub: "GABC123", exp: 1 }); + const response = middleware(makeRequest("/dashboard", expired)); + + expect(redirectTarget(response)?.pathname).toBe("/connect"); + expect(response.cookies.get("chainlearn-session")?.value).toBe(""); + }); + + it.each([ + "", + "not-a-jwt", + "a.b.c", + "..", + "eyJhbGciOiJub25lIn0.e30.c2ln", + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE5MDAwMDAwMDB9.a", + ])( + "rejects malformed session token %s and clears the cookie", + (token) => { + const response = middleware(makeRequest("/dashboard", token)); + + expect(redirectTarget(response)?.pathname).toBe("/connect"); + expect(response.cookies.get("chainlearn-session")?.value).toBe(""); + } + ); }); describe("public routes", () => { @@ -93,11 +124,12 @@ describe("public routes", () => { }); it.each(PUBLIC)("leaves %s accessible when signed in", (pathname) => { - expect(redirectTarget(middleware(makeRequest(pathname, "jwt-abc")))).toBeNull(); + expect( + redirectTarget(middleware(makeRequest(pathname, VALID_SESSION))) + ).toBeNull(); }); it("treats the /courses listing as public but its detail pages as protected", () => { - // The prefix check must distinguish "/courses" from "/courses/". expect(redirectTarget(middleware(makeRequest("/courses")))).toBeNull(); expect(redirectTarget(middleware(makeRequest("/courses/abc")))?.pathname).toBe( "/connect" @@ -111,24 +143,23 @@ describe("public routes", () => { describe("auth routes", () => { it("bounces a signed-in visitor away from /connect", () => { - expect(redirectTarget(middleware(makeRequest("/connect", "jwt-abc")))?.pathname).toBe( - "/dashboard" - ); + expect( + redirectTarget(middleware(makeRequest("/connect", VALID_SESSION)))?.pathname + ).toBe("/dashboard"); }); it("bounces a signed-in visitor away from /onboarding", () => { expect( - redirectTarget(middleware(makeRequest("/onboarding", "jwt-abc")))?.pathname + redirectTarget(middleware(makeRequest("/onboarding", VALID_SESSION)))?.pathname ).toBe("/dashboard"); }); it("honours the redirect parameter after signing in", () => { const target = redirectTarget( - middleware(makeRequest("/connect", "jwt-abc", "?redirect=/rewards")) + middleware(makeRequest("/connect", VALID_SESSION, "?redirect=/rewards")) ); expect(target?.pathname).toBe("/rewards"); - // The parameter is consumed, not carried into the destination. expect(target?.searchParams.get("redirect")).toBeNull(); }); @@ -152,8 +183,6 @@ describe("matcher", () => { }); it("does not run on public routes", () => { - // Scoping the matcher is what keeps Edge cold-start cost off the marketing - // pages and the public verification view. for (const pattern of config.matcher) { expect(pattern).not.toBe("/"); expect(pattern).not.toMatch(/^\/verify/); @@ -164,9 +193,6 @@ describe("matcher", () => { describe("edge runtime compatibility", () => { it("runs without Node built-ins available", () => { - // The middleware must not reach for anything outside the Edge API surface; - // a stray Node import would fail at deploy, not in a unit test, so this - // asserts the request path stays on Web APIs only. expect(() => middleware(makeRequest("/dashboard"))).not.toThrow(); expect(() => middleware(makeRequest("/dashboard", "jwt"))).not.toThrow(); }); diff --git a/src/middleware.ts b/src/middleware.ts index d8794f8..8ffdaa4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -20,6 +20,21 @@ const PROTECTED_PREFIXES = [ */ const AUTH_PREFIXES = ["/connect", "/onboarding"]; +function decodeBase64UrlJson(segment: string): unknown | null { + try { + const normalized = segment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + "=" + ); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + return null; + } +} + /** * /courses (the listing page) is public — only sub-paths are protected. * This helper distinguishes "/courses" from "/courses//…". @@ -32,43 +47,81 @@ function isAuthRoute(pathname: string): boolean { return AUTH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } -/** - * Decode JWT payload and check expiry (#495). - * Uses atob() for base64 decoding — works in Edge runtime. - * Malformed tokens are treated as expired. - */ -function isJwtExpired(token: string): boolean { +/** Validate JWT structure and expiry; signature verification remains server-side. */ +function isValidSessionToken(token: string): boolean { try { const parts = token.split("."); - if (parts.length !== 3) return true; - const payload = JSON.parse(atob(parts[1])); - if (typeof payload.exp !== "number") return true; - // exp is in seconds; compare with current time in seconds - return payload.exp * 1000 < Date.now(); + if ( + parts.length !== 3 || + parts.some( + (part) => + !part || part.length % 4 === 1 || !/^[A-Za-z0-9_-]+$/.test(part) + ) + ) { + return false; + } + + const header = decodeBase64UrlJson(parts[0]); + if ( + !header || + typeof header !== "object" || + typeof (header as { alg?: unknown }).alg !== "string" || + (header as { alg: string }).alg.toLowerCase() === "none" + ) { + return false; + } + + const payload = decodeBase64UrlJson(parts[1]); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + const expiry = (payload as { exp?: unknown }).exp; + return ( + typeof expiry === "number" && + Number.isFinite(expiry) && + expiry * 1000 > Date.now() + ); } catch { - return true; // malformed tokens are treated as expired + return false; + } +} + +function redirectToConnect(request: NextRequest, clearCookie = false) { + const url = request.nextUrl.clone(); + url.pathname = "/connect"; + if (isProtected(request.nextUrl.pathname)) { + url.searchParams.set("redirect", request.nextUrl.pathname); } + const response = NextResponse.redirect(url); + if (clearCookie) response.cookies.delete("chainlearn-session"); + return response; } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - const sessionToken = request.cookies.get("chainlearn-session")?.value; + const sessionCookie = request.cookies.get("chainlearn-session"); + const sessionToken = sessionCookie?.value; + const validSession = sessionToken ? isValidSessionToken(sessionToken) : false; + + if (sessionCookie && !validSession) { + if (pathname === "/connect") { + const response = NextResponse.next(); + response.cookies.delete("chainlearn-session"); + return response; + } + return redirectToConnect(request, true); + } // --- Guard protected routes --- if (isProtected(pathname)) { - if (!sessionToken || isJwtExpired(sessionToken)) { - const url = request.nextUrl.clone(); - url.pathname = "/connect"; - // Preserve the intended destination so the connect page can redirect - // back after a successful wallet login. - url.searchParams.set("redirect", pathname); - return NextResponse.redirect(url); + if (!validSession) { + return redirectToConnect(request); } } // --- Bounce authenticated users away from auth pages --- if (isAuthRoute(pathname)) { - if (sessionToken && !isJwtExpired(sessionToken)) { + if (validSession) { const redirect = request.nextUrl.searchParams.get("redirect"); const url = request.nextUrl.clone(); url.pathname = redirect || "/dashboard"; diff --git a/src/types/api.ts b/src/types/api.ts index 8637704..21ac723 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -27,8 +27,9 @@ export interface RequestConfig extends Omit { } export interface ResponseInterceptor { - onSuccess?: (response: T) => T | Promise; - onError?: (error: unknown) => never | Promise; + onResponse?: (response: Response) => Response | Promise; + onSuccess?: (data: ApiResponse) => ApiResponse | Promise>; + onError?: (error: unknown) => void | Promise; } export interface UserSession {