From f4a83d0d1e092815fb57f01711c3f16118be2968 Mon Sep 17 00:00:00 2001 From: maramina Date: Wed, 21 Oct 2026 23:02:56 +0100 Subject: [PATCH] Fix API empty responses and report CSP violations --- next.config.mjs | 3 +- src/app/api/csp-report/route.ts | 19 ++++++ src/app/courses/[courseId]/layout.tsx | 4 +- src/app/credentials/[credentialId]/layout.tsx | 4 +- src/app/verify/[credentialId]/layout.tsx | 4 +- src/lib/api/auth.ts | 18 +++--- src/lib/api/client.test.ts | 59 +++++++++++++++++++ src/lib/api/client.ts | 49 ++++++++++----- src/lib/api/courses.ts | 16 ++--- src/lib/api/credentials.ts | 30 ++-------- src/lib/api/notifications.ts | 4 +- src/lib/api/quizzes.ts | 10 ++-- src/lib/api/rewards.ts | 10 ++-- 13 files changed, 153 insertions(+), 77 deletions(-) create mode 100644 src/app/api/csp-report/route.ts diff --git a/next.config.mjs b/next.config.mjs index 754a019..f2b14ac 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -52,13 +52,14 @@ const ContentSecurityPolicy = [ "base-uri 'self'", "form-action 'self'", "object-src 'none'", + "report-uri /api/csp-report", // Avoid breaking local HTTP; enable HSTS-style upgrade only in production ...(isDev ? [] : ["upgrade-insecure-requests"]), ].join("; "); const securityHeaders = [ { - key: "Content-Security-Policy", + key: "Content-Security-Policy-Report-Only", value: ContentSecurityPolicy, }, { diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts new file mode 100644 index 0000000..fb0784b --- /dev/null +++ b/src/app/api/csp-report/route.ts @@ -0,0 +1,19 @@ +const MAX_REPORT_SIZE = 64 * 1024; + +export async function POST(request: Request) { + const body = await request.text(); + if (body.length > MAX_REPORT_SIZE) { + return new Response(null, { status: 413 }); + } + + try { + const report: unknown = JSON.parse(body); + if (report === null || typeof report !== "object") { + return new Response(null, { status: 400 }); + } + console.warn("[CSP violation report]", report); + return new Response(null, { status: 204 }); + } catch { + return new Response(null, { status: 400 }); + } +} \ No newline at end of file diff --git a/src/app/courses/[courseId]/layout.tsx b/src/app/courses/[courseId]/layout.tsx index 5d13fd9..54aabe0 100644 --- a/src/app/courses/[courseId]/layout.tsx +++ b/src/app/courses/[courseId]/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { apiClient } from "@/lib/api/client"; +import { apiClient, requireApiData } from "@/lib/api/client"; import type { Course } from "@/types/course"; interface PageProps { @@ -11,7 +11,7 @@ export async function generateMetadata({ params }: PageProps): Promise try { const response = await apiClient.get(`/courses/${courseId}`); - const course = response.data; + const course = requireApiData(response.data); const images = course.imageUrl ? [ { diff --git a/src/app/credentials/[credentialId]/layout.tsx b/src/app/credentials/[credentialId]/layout.tsx index 501eb32..0074cae 100644 --- a/src/app/credentials/[credentialId]/layout.tsx +++ b/src/app/credentials/[credentialId]/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { apiClient } from "@/lib/api/client"; +import { apiClient, requireApiData } from "@/lib/api/client"; import type { CredentialNFT } from "@/types/stellar"; interface PageProps { @@ -13,7 +13,7 @@ export async function generateMetadata({ params }: PageProps): Promise const response = await apiClient.get( `/credentials/${credentialId}` ); - const credential = response.data; + const credential = requireApiData(response.data); const title = `${credential.courseTitle} - Credential`; const description = `On-chain credential for ${credential.courseTitle} on ChainLearn.`; diff --git a/src/app/verify/[credentialId]/layout.tsx b/src/app/verify/[credentialId]/layout.tsx index 3d7fb0f..22ffe93 100644 --- a/src/app/verify/[credentialId]/layout.tsx +++ b/src/app/verify/[credentialId]/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { apiClient } from "@/lib/api/client"; +import { apiClient, requireApiData } from "@/lib/api/client"; interface PageProps { params: { credentialId: string }; @@ -13,7 +13,7 @@ export async function generateMetadata({ params }: PageProps): Promise valid: boolean; metadata: { courseTitle: string }; }>(`/credentials/${credentialId}/verify`); - const data = response.data; + const data = requireApiData(response.data); const title = `Verify - ${data.metadata.courseTitle}`; const description = `On-chain verification of ${data.metadata.courseTitle} credential on ChainLearn.`; diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index b4fcc8d..92f362a 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./client"; +import { apiClient, requireApiData } from "./client"; import type { AuthTokens, UserProfile, UserSession } from "@/types/api"; import { useAuthStore } from "@/store/auth-store"; import { getTokenExpiry, isTokenExpired } from "@/lib/utils/jwt"; @@ -20,7 +20,7 @@ export async function getChallenge( undefined, signal ); - return response.data.challenge; + return requireApiData(response.data).challenge; } /** @@ -40,7 +40,7 @@ export async function verifySignature( undefined, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -53,7 +53,7 @@ export async function getProfile( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.get("/auth/profile", token, signal); - return response.data; + return requireApiData(response.data); } /** @@ -72,7 +72,7 @@ export async function updateProfile( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -89,7 +89,7 @@ export async function refreshToken( undefined, signal ); - return response.data; + return requireApiData(response.data); } catch (error) { // Fallback to /api/v1/auth/refresh if configured try { @@ -99,7 +99,7 @@ export async function refreshToken( undefined, signal ); - return response.data; + return requireApiData(response.data); } catch { throw error; } @@ -179,7 +179,7 @@ export async function getSessions( const validToken = await getValidToken(); const token = validToken || jwt; const response = await apiClient.get("/auth/sessions", token, signal); - return response.data; + return requireApiData(response.data); } /** @@ -197,5 +197,5 @@ export async function revokeSession( token, signal ); - return response.data; + return requireApiData(response.data); } diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts index d9d6886..53a8fb1 100644 --- a/src/lib/api/client.test.ts +++ b/src/lib/api/client.test.ts @@ -197,6 +197,62 @@ describe("apiClient request deduplication", () => { }); describe("apiClient response interceptors", () => { + it("returns null data for successful empty responses", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 204, + statusText: "No Content", + text: async () => "", + } as unknown as Response); + + await expect( + apiClient.get("/empty-response", undefined, undefined, { bypassCache: true }) + ).resolves.toEqual({ data: null, success: true }); + }); + + it("returns null data for a successful response with a blank body", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: async () => " \n", + } as unknown as Response); + + await expect( + apiClient.get("/blank-response", undefined, undefined, { bypassCache: true }) + ).resolves.toEqual({ data: null, success: true }); + }); + + it("wraps malformed successful JSON in ApiError and notifies error interceptors", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: async () => "proxy error", + } as unknown as Response); + + const loggedErrors: unknown[] = []; + const remove = apiClient.addResponseInterceptor({ + onError: (error) => { + loggedErrors.push(error); + }, + }); + + try { + await expect( + apiClient.get("/malformed-json", undefined, undefined, { bypassCache: true }) + ).rejects.toMatchObject({ + name: "ApiError", + status: 200, + message: expect.stringContaining("Invalid JSON"), + }); + expect(loggedErrors).toHaveLength(1); + expect(loggedErrors[0]).toMatchObject({ name: "ApiError", status: 200 }); + } finally { + remove(); + } + }); + it("runs onResponse and onSuccess and can transform responses", async () => { fetchMock.mockResolvedValue(jsonResponse({ success: true, data: { value: 10 } })); @@ -317,6 +373,9 @@ describe("apiClient retry backoff (issue #311)", () => { expect(payload.delayMs).toBe(1000); warnSpy.mockRestore(); + }); +}); + // ── Issue #312: LRU + per-call TTL + cached-response cloning ───────────────── describe("apiClient cache behaviour (issue #312)", () => { diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 5456539..bdcb427 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -62,6 +62,13 @@ function logRetry(entry: { const CACHE_TTL_MS = 60_000; const CACHE_MAX_SIZE = 100; +export function requireApiData(data: T | null): T { + if (data === null) { + throw new ApiError(200, "The API returned an empty response body."); + } + return data; +} + type CacheEntry = { value: unknown; expiresAt: number; @@ -155,7 +162,9 @@ export type ResponseInterceptor = { * Post-processing interceptor running after `handleResponse`. * Can inspect or transform the parsed response data. */ - onSuccess?: (data: ApiResponse) => ApiResponse | Promise>; + onSuccess?: ( + data: ApiResponse + ) => ApiResponse | Promise>; /** * Error interceptor running whenever an error is encountered during a request. * Can be used for global error logging or analytics events. @@ -190,7 +199,7 @@ class ApiClient { * Runs onResponse interceptors (pre-processing) then handleResponse, * then onSuccess interceptors (post-processing). */ - private async processResponse(response: Response): Promise> { + private async processResponse(response: Response): Promise> { let currentResponse = response; for (const interceptor of this.responseInterceptors) { if (interceptor.onResponse) { @@ -198,11 +207,11 @@ class ApiClient { } } - let data = await this.handleResponse>(currentResponse); + let data = await this.handleResponse(currentResponse); for (const interceptor of this.responseInterceptors) { if (interceptor.onSuccess) { - data = await interceptor.onSuccess(data); + data = (await interceptor.onSuccess(data)) as ApiResponse; } } @@ -236,7 +245,7 @@ class ApiClient { return headers; } - private async handleResponse(response: Response): Promise { + private async handleResponse(response: Response): Promise> { if (!response.ok) { if (response.status === 401) { useAuthStore.getState().disconnect(); @@ -250,14 +259,22 @@ class ApiClient { } if (response.status === 204) { - return undefined as T; + return { data: null, success: true }; } const text = await response.text(); - if (!text) { - return undefined as T; + if (!text.trim()) { + return { data: null, success: true }; + } + try { + return JSON.parse(text) as ApiResponse; + } catch (error) { + const detail = error instanceof Error ? error.message : "Unknown parsing error"; + throw new ApiError( + response.status, + `Invalid JSON in successful API response: ${detail}` + ); } - return JSON.parse(text) as T; } private isTransientError(error: Error): boolean { @@ -423,12 +440,12 @@ class ApiClient { jwt?: string, signal?: AbortSignal, options?: { bypassCache?: boolean; timeout?: number; ttl?: number } - ): Promise> { + ): Promise> { const url = `${this.baseUrl}${path}`; const key = this.cacheKey(url, jwt); if (!options?.bypassCache) { - const cached = this.getCached>(key); + const cached = this.getCached>(key); if (cached !== undefined) { return cached; } @@ -474,7 +491,7 @@ class ApiClient { entry.refs++; try { - return (await withAbort(entry.promise, signal)) as ApiResponse; + return (await withAbort(entry.promise, signal)) as ApiResponse; } finally { entry.refs--; // Last caller gone before the request finished — cancel it for real. @@ -497,7 +514,7 @@ class ApiClient { signal?: AbortSignal, timeout?: number, ttl?: number, - ): Promise> { + ): Promise> { try { const response = await this.fetchWithRetry( url, @@ -534,7 +551,7 @@ class ApiClient { jwt?: string, signal?: AbortSignal, timeout?: number - ): Promise> { + ): Promise> { try { const response = await this.fetchWithRetry( `${this.baseUrl}${path}`, @@ -571,7 +588,7 @@ class ApiClient { jwt?: string, signal?: AbortSignal, timeout?: number - ): Promise> { + ): Promise> { try { const response = await this.fetchWithRetry( `${this.baseUrl}${path}`, @@ -607,7 +624,7 @@ class ApiClient { jwt?: string, signal?: AbortSignal, timeout?: number - ): Promise> { + ): Promise> { try { const response = await this.fetchWithRetry( `${this.baseUrl}${path}`, diff --git a/src/lib/api/courses.ts b/src/lib/api/courses.ts index af1931f..968b4d1 100644 --- a/src/lib/api/courses.ts +++ b/src/lib/api/courses.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./client"; +import { apiClient, requireApiData } from "./client"; import { getValidToken } from "./auth"; import type { PaginatedResponse } from "@/types/api"; import { getQuiz, submitQuiz } from "./quizzes"; @@ -66,7 +66,7 @@ export async function getCourses( undefined, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -78,7 +78,7 @@ export async function getCourse( signal?: AbortSignal ): Promise { const response = await apiClient.get(`/courses/${courseId}`, jwt, signal); - return response.data; + return requireApiData(response.data); } /** @@ -97,7 +97,7 @@ export async function enrollInCourse( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -122,7 +122,7 @@ export async function getModule( jwt, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -152,7 +152,7 @@ export async function markModuleComplete( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -169,7 +169,7 @@ export async function getEnrollments( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -186,7 +186,7 @@ export async function getRecommendedCourses( token, signal ); - return response.data; + return requireApiData(response.data); } /** diff --git a/src/lib/api/credentials.ts b/src/lib/api/credentials.ts index 7b8e0b2..0b6935b 100644 --- a/src/lib/api/credentials.ts +++ b/src/lib/api/credentials.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./client"; +import { apiClient, requireApiData } from "./client"; import { ApiError } from "@/types/api"; import type { CredentialNFT } from "@/types/stellar"; @@ -21,7 +21,6 @@ export interface VerifyCredentialResult { error?: VerifyCredentialError; } import { getValidToken } from "./auth"; -import type { CredentialNFT, CredentialMetadata } from "@/types/stellar"; /** * Fetch all credentials for the authenticated user. @@ -37,7 +36,7 @@ export async function getCredentials( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -53,29 +52,10 @@ export async function getCredential( jwt, signal ); - return response.data; + return requireApiData(response.data); } /** - * Verify a credential publicly (no auth required). - * #507 — Fix property name: API returns data wrapped in ApiResponse, so we - * need to extract .data from the response envelope. - */ -export async function verifyCredential( - credentialId: string -): Promise<{ - valid: boolean; - metadata: CredentialMetadata; - verifiedAt: string; -}> { - const response = await apiClient.get<{ - valid: boolean; - metadata: CredentialMetadata; - verifiedAt: string; - }>(`/credentials/${credentialId}/verify`); - // ApiResponse wraps data in { data, message?, success }, so extract .data - return response.data; - * * Calls `GET /credentials/verify/:id` and returns a typed * `VerifyCredentialResult`. A 404 for a nonexistent credential is folded * into `{ valid: false, error: "not_found" }` so callers do not have to @@ -94,7 +74,7 @@ export async function verifyCredential( undefined, signal ); - return response.data; + return requireApiData(response.data); } catch (err) { if (err instanceof ApiError && err.status === 404) { return { valid: false, error: "not_found" }; @@ -119,5 +99,5 @@ export async function mintCredential( token, signal ); - return response.data; + return requireApiData(response.data); } diff --git a/src/lib/api/notifications.ts b/src/lib/api/notifications.ts index 4aebced..5b7dc66 100644 --- a/src/lib/api/notifications.ts +++ b/src/lib/api/notifications.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./client"; +import { apiClient, requireApiData } from "./client"; import { getValidToken } from "./auth"; import type { AppNotification } from "@/types/notification"; @@ -16,7 +16,7 @@ export async function getNotifications( token, signal ); - return response.data; + return requireApiData(response.data); } /** diff --git a/src/lib/api/quizzes.ts b/src/lib/api/quizzes.ts index 807dad7..83d33c2 100644 --- a/src/lib/api/quizzes.ts +++ b/src/lib/api/quizzes.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./client"; +import { apiClient, requireApiData } from "./client"; import { getValidToken } from "./auth"; import type { Quiz, @@ -39,7 +39,7 @@ export async function getQuiz( jwt, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -70,7 +70,7 @@ export async function generateQuiz( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -103,7 +103,7 @@ export async function submitQuiz( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -129,5 +129,5 @@ export async function getQuizAttempts( token, signal ); - return response.data; + return requireApiData(response.data); } diff --git a/src/lib/api/rewards.ts b/src/lib/api/rewards.ts index d5e82f2..0a36222 100644 --- a/src/lib/api/rewards.ts +++ b/src/lib/api/rewards.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./client"; +import { apiClient, requireApiData } from "./client"; import { getValidToken } from "./auth"; import type { PaginatedResponse } from "@/types/api"; import type { RewardClaim, TokenBalance } from "@/types/stellar"; @@ -31,7 +31,7 @@ export async function getTokenBalances( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -69,7 +69,7 @@ export async function getRewardHistory( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -88,7 +88,7 @@ export async function claimReward( token, signal ); - return response.data; + return requireApiData(response.data); } /** @@ -103,5 +103,5 @@ export async function getClaimables( const response = await apiClient.get< { id: string; amount: string; source: string; sourceTitle: string }[] >("/rewards/claimables", token, signal); - return response.data; + return requireApiData(response.data); }