diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 66d07c31..59854c5b 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express'; import { BadRequestError } from '../../common/errors/AppError.js'; import { + createAuditLogSchema, listAuditLogsQuerySchema, platformStatsResponseSchema, } from './admin.schema.js'; @@ -58,7 +59,11 @@ export async function createAuditLogController( throw new BadRequestError('Unauthorized'); } - const { action, target, metadata } = req.body; + const parsed = createAuditLogSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError('Invalid audit log payload', parsed.error.issues); + } + const { action, target, metadata } = parsed.data; const log = await logAuditAction( auth.sub, diff --git a/backend/src/modules/admin/admin.schema.ts b/backend/src/modules/admin/admin.schema.ts index e5212565..18b813b6 100644 --- a/backend/src/modules/admin/admin.schema.ts +++ b/backend/src/modules/admin/admin.schema.ts @@ -4,14 +4,14 @@ export const createAuditLogSchema = z.object({ action: z.string().min(1).max(255), target: z.string().optional().nullable(), metadata: z.record(z.unknown()).default({}), -}); +}).strict(); export const listAuditLogsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0), action: z.string().optional(), actor: z.string().optional(), -}); +}).strict(); export const platformStatsResponseSchema = z.object({ totalUsers: z.number(), @@ -22,4 +22,4 @@ export const platformStatsResponseSchema = z.object({ totalSubscriptions: z.number(), totalRefunds: z.number(), averageTipAmount: z.string(), -}); +}).strict(); diff --git a/backend/src/modules/admin/config.schema.ts b/backend/src/modules/admin/config.schema.ts index 9a220385..f94faf46 100644 --- a/backend/src/modules/admin/config.schema.ts +++ b/backend/src/modules/admin/config.schema.ts @@ -26,44 +26,44 @@ const stroopsString = z export const prepareSetFeeSchema = z.object({ /** New fee in basis points. Contract bound: 0–1000 (max 10%). */ feeBps: z.number().int().min(0).max(1000, 'fee_bps must be ≤ 1000 (10%)'), -}); +}).strict(); export const prepareSetMinTipAmountSchema = z.object({ /** New minimum tip amount in stroops (>= 0). */ amount: stroopsString, -}); +}).strict(); export const prepareSetMinWithdrawalAmountSchema = z.object({ /** New minimum withdrawal amount in stroops (>= 0). */ amount: stroopsString, -}); +}).strict(); export const preparePauseSchema = z.object({ /** true = pause, false = unpause. */ paused: z.boolean(), -}); +}).strict(); // ── Submit schemas ──────────────────────────────────────────────────────────── export const submitSetFeeSchema = z.object({ feeBps: z.number().int().min(0).max(1000, 'fee_bps must be ≤ 1000 (10%)'), signedTxXdr: z.string().min(1, 'signedTxXdr is required'), -}); +}).strict(); export const submitSetMinTipAmountSchema = z.object({ amount: stroopsString, signedTxXdr: z.string().min(1, 'signedTxXdr is required'), -}); +}).strict(); export const submitSetMinWithdrawalAmountSchema = z.object({ amount: stroopsString, signedTxXdr: z.string().min(1, 'signedTxXdr is required'), -}); +}).strict(); export const submitPauseSchema = z.object({ paused: z.boolean(), signedTxXdr: z.string().min(1, 'signedTxXdr is required'), -}); +}).strict(); // ── Types ───────────────────────────────────────────────────────────────────── diff --git a/backend/src/modules/analytics/analytics.schema.ts b/backend/src/modules/analytics/analytics.schema.ts index 40c63367..250546fe 100644 --- a/backend/src/modules/analytics/analytics.schema.ts +++ b/backend/src/modules/analytics/analytics.schema.ts @@ -6,7 +6,7 @@ export const analyticsDailyQuerySchema = z.object({ endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format').optional(), limit: z.coerce.number().int().min(1).max(365).default(30), offset: z.coerce.number().int().min(0).default(0), -}); +}).strict(); export type AnalyticsDailyQuery = z.infer; @@ -15,7 +15,7 @@ export const volumeQuerySchema = z.object({ granularity: z.enum(['day', 'week', 'month']).default('day'), startDate: z.string().datetime({ offset: true }).optional(), endDate: z.string().datetime({ offset: true }).optional(), -}); +}).strict(); export type VolumeQuery = z.infer; @@ -23,7 +23,7 @@ export type VolumeQuery = z.infer; export const topTippersQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), -}); +}).strict(); export type TopTippersQuery = z.infer; @@ -32,13 +32,13 @@ export const activeUsersQuerySchema = z.object({ granularity: z.enum(['day', 'week', 'month']).default('day'), startDate: z.string().datetime({ offset: true }).optional(), endDate: z.string().datetime({ offset: true }).optional(), -}); +}).strict(); export type ActiveUsersQuery = z.infer; /** Path parameters for GET /analytics/creators/:username. */ export const creatorUsernameParamSchema = z.object({ username: z.string().min(1, 'Username is required').max(50), -}); +}).strict(); export type CreatorUsernameParam = z.infer; @@ -47,6 +47,6 @@ export const creatorAnalyticsQuerySchema = z.object({ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format').optional(), endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format').optional(), granularity: z.enum(['day', 'week', 'month']).default('day'), -}); +}).strict(); export type CreatorAnalyticsQuery = z.infer; diff --git a/backend/src/modules/apiKeys/apiKeys.schema.ts b/backend/src/modules/apiKeys/apiKeys.schema.ts index 31c2a1f9..31eab0aa 100644 --- a/backend/src/modules/apiKeys/apiKeys.schema.ts +++ b/backend/src/modules/apiKeys/apiKeys.schema.ts @@ -3,15 +3,15 @@ import { z } from "zod"; export const createApiKeySchema = z.object({ scopes: z.array(z.string().min(1)).min(1, "At least one scope is required"), expiresAt: z.string().datetime().optional(), -}); +}).strict(); export const rotateApiKeySchema = z.object({ gracePeriodMinutes: z.coerce.number().int().positive().max(10080).optional(), -}); +}).strict(); export const apiKeyIdParamSchema = z.object({ id: z.string().min(1, "API key ID is required"), -}); +}).strict(); export type CreateApiKeyInput = z.infer; export type RotateApiKeyInput = z.infer; diff --git a/backend/src/modules/auth/auth.schema.ts b/backend/src/modules/auth/auth.schema.ts index a2d7e463..f2048451 100644 --- a/backend/src/modules/auth/auth.schema.ts +++ b/backend/src/modules/auth/auth.schema.ts @@ -9,18 +9,18 @@ import { z } from "zod"; export const challengeSchema = z.object({ stellarAddress: z.string().min(1, "Stellar address is required"), network: z.enum(["TESTNET", "FUTURENET", "MAINNET"]).optional(), -}); +}).strict(); export const verifySchema = z.object({ stellarAddress: z.string().min(1, "Stellar address is required"), signature: z.string().min(1, "Signature is required"), challenge: z.string().min(1, "Challenge is required"), network: z.enum(["TESTNET", "FUTURENET", "MAINNET"]).optional(), -}); +}).strict(); export const refreshSchema = z.object({ refreshToken: z.string().min(1, "Refresh token is required"), -}); +}).strict(); export type ChallengeInput = z.infer; export type VerifyInput = z.infer; diff --git a/backend/src/modules/credit/credit.schema.ts b/backend/src/modules/credit/credit.schema.ts index 4a206d55..4eadf5fa 100644 --- a/backend/src/modules/credit/credit.schema.ts +++ b/backend/src/modules/credit/credit.schema.ts @@ -2,20 +2,20 @@ import { z } from 'zod'; export const creditIdentifierParamSchema = z.object({ identifier: z.string().min(1).max(100), -}); +}).strict(); export const userIdParamSchema = z.object({ userId: z.string().min(1), -}); +}).strict(); export const recalculateSchema = z.object({ userId: z.string().min(1), -}); +}).strict(); export const creditHistoryQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0), -}); +}).strict(); export type CreditIdentifierParam = z.infer; export type UserIdParam = z.infer; diff --git a/backend/src/modules/discovery/discovery.schema.ts b/backend/src/modules/discovery/discovery.schema.ts index bfce209d..de0c58a4 100644 --- a/backend/src/modules/discovery/discovery.schema.ts +++ b/backend/src/modules/discovery/discovery.schema.ts @@ -3,8 +3,8 @@ import { z } from 'zod'; export const trendingQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0), -}); +}).strict(); export const similarQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), -}); +}).strict(); diff --git a/backend/src/modules/email/email.schema.ts b/backend/src/modules/email/email.schema.ts index 6f1bbaf6..c599800c 100644 --- a/backend/src/modules/email/email.schema.ts +++ b/backend/src/modules/email/email.schema.ts @@ -7,6 +7,6 @@ export const sendEmailSchema = z.object({ html: z.string().max(20_000).optional(), type: z.string().min(1).max(80).optional(), metadata: z.record(z.string(), z.unknown()).optional(), -}); +}).strict(); export type SendEmailInput = z.infer; diff --git a/backend/src/modules/goals/goals.schema.ts b/backend/src/modules/goals/goals.schema.ts index e3c83475..f126480f 100644 --- a/backend/src/modules/goals/goals.schema.ts +++ b/backend/src/modules/goals/goals.schema.ts @@ -4,24 +4,24 @@ export const createGoalSchema = z.object({ title: z.string().min(1).max(200), targetStroops: z.string().regex(/^\d+$/, 'Must be a positive integer string'), deadline: z.string().datetime({ offset: true }).optional(), -}); +}).strict(); export const updateGoalSchema = z.object({ title: z.string().min(1).max(200).optional(), targetStroops: z.string().regex(/^\d+$/, 'Must be a positive integer string').optional(), deadline: z.string().datetime({ offset: true }).nullable().optional(), status: z.enum(['ACTIVE', 'CANCELLED']).optional(), -}); +}).strict(); export const goalIdSchema = z.object({ id: z.string().min(1), -}); +}).strict(); export const goalListQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0), status: z.enum(['ACTIVE', 'COMPLETED', 'CANCELLED', 'EXPIRED']).optional(), -}); +}).strict(); export type CreateGoalInput = z.infer; export type UpdateGoalInput = z.infer; diff --git a/backend/src/modules/ipfs/ipfs.schema.ts b/backend/src/modules/ipfs/ipfs.schema.ts index 915a63be..a3cc5b03 100644 --- a/backend/src/modules/ipfs/ipfs.schema.ts +++ b/backend/src/modules/ipfs/ipfs.schema.ts @@ -18,14 +18,14 @@ export const cidParamSchema = z.object({ .trim() .min(1, "CID cannot be empty") .regex(cidRegex, "Invalid IPFS CID format"), -}); +}).strict(); /** * Zod schema for gateway query parameters. */ export const gatewayQuerySchema = z.object({ gateway: z.string().url("Invalid gateway URL format").optional(), -}); +}).strict(); export type CidParamInput = z.infer; export type GatewayQueryInput = z.infer; diff --git a/backend/src/modules/ipfs/ipfs.service.ts b/backend/src/modules/ipfs/ipfs.service.ts index c638fa33..105a19d1 100644 --- a/backend/src/modules/ipfs/ipfs.service.ts +++ b/backend/src/modules/ipfs/ipfs.service.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import sharp from "sharp"; import { config } from "../../config/index.js"; import { logger } from "../../common/utils/logger.js"; import { @@ -13,42 +14,111 @@ import type { IpfsUploadResponse } from "./ipfs.types.js"; /** Default max file size limit for image uploads (5 MB) */ export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; -/** Supported image MIME types */ -export const ALLOWED_IMAGE_MIME_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", - "image/svg+xml", -]; +/** Maximum allowed pixel width/height for an uploaded image (issue #1232) */ +export const MAX_IMAGE_DIMENSION_PX = 4096; /** - * Validates an uploaded image file for MIME type and size constraints. + * Raster formats accepted for uploads, matched against the format sharp + * actually decodes from the file's magic bytes — never the client-supplied + * MIME type or filename extension. SVG is deliberately excluded: it is a + * markup format capable of carrying scripts, so it cannot be "verified" the + * same way a raster image can (issue #1232). + */ +const ALLOWED_IMAGE_FORMATS = ["jpeg", "png", "gif", "webp"] as const; +type AllowedImageFormat = (typeof ALLOWED_IMAGE_FORMATS)[number]; + +const FORMAT_TO_MIME_TYPE: Record = { + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", +}; + +/** Legacy export kept for callers that need a human-readable allowlist. */ +export const ALLOWED_IMAGE_MIME_TYPES = Object.values(FORMAT_TO_MIME_TYPE); + +export interface SanitizedImage { + /** Re-encoded image bytes with all metadata (EXIF/GPS) stripped. */ + buffer: Buffer; + mimeType: string; + format: AllowedImageFormat; + width: number; + height: number; +} + +/** + * Verifies an uploaded image by its actual decoded content and returns a + * sanitized, re-encoded copy safe to pin (issue #1232). * - * @param file Express Multer file object or raw file buffer with metadata. - * @throws BadRequestError if the file is missing, unsupported, or exceeds size limits. + * - File type is verified by magic bytes (via sharp/libvips format + * detection), never by the client-supplied MIME type or file extension. + * - Only a fixed allowlist of raster formats is accepted; SVG is rejected + * outright since it cannot be decoded into pixels the same way. + * - Dimensions are bounded to guard against decompression-bomb style images. + * - The output is a fresh re-encode of the decoded pixels, which drops all + * EXIF/IPTC/XMP metadata (including GPS) and any bytes that do not belong + * to the actual image data — the standard defense against polyglot files + * that smuggle a payload past a format's end-of-data marker. + * + * @throws BadRequestError if the file is missing, unreadable, not an + * allowed format, or exceeds the size/dimension limits. */ -export function validateImageFile(file?: { - mimetype: string; +export async function verifyAndSanitizeImage(file?: { size: number; buffer: Buffer; -}): void { +}): Promise { if (!file || !file.buffer || file.buffer.length === 0) { throw new BadRequestError("No image file provided"); } - if (!ALLOWED_IMAGE_MIME_TYPES.includes(file.mimetype.toLowerCase())) { + if (file.size > MAX_IMAGE_SIZE_BYTES) { throw new BadRequestError( - `Unsupported file type '${file.mimetype}'. Allowed types: ${ALLOWED_IMAGE_MIME_TYPES.join(", ")}` + `File size exceeds maximum limit of ${MAX_IMAGE_SIZE_BYTES / (1024 * 1024)}MB` ); } - if (file.size > MAX_IMAGE_SIZE_BYTES) { + let metadata: sharp.Metadata; + try { + metadata = await sharp(file.buffer).metadata(); + } catch { throw new BadRequestError( - `File size exceeds maximum limit of ${MAX_IMAGE_SIZE_BYTES / (1024 * 1024)}MB` + "File content could not be verified as a valid image. It may be corrupt, an unsupported format, or not an image at all." ); } + + const format = metadata.format; + if (!format || !(ALLOWED_IMAGE_FORMATS as readonly string[]).includes(format)) { + throw new BadRequestError( + `Unsupported or unverifiable image format '${format ?? "unknown"}'. Allowed types: ${ALLOWED_IMAGE_FORMATS.join(", ")}` + ); + } + const verifiedFormat = format as AllowedImageFormat; + + if (!metadata.width || !metadata.height) { + throw new BadRequestError("Unable to determine image dimensions"); + } + if (metadata.width > MAX_IMAGE_DIMENSION_PX || metadata.height > MAX_IMAGE_DIMENSION_PX) { + throw new BadRequestError( + `Image dimensions (${metadata.width}x${metadata.height}) exceed the maximum of ${MAX_IMAGE_DIMENSION_PX}x${MAX_IMAGE_DIMENSION_PX}px` + ); + } + + try { + const sanitizedBuffer = await sharp(file.buffer, { animated: verifiedFormat === "gif" }) + .rotate() // bake in EXIF orientation before metadata is dropped + .toFormat(verifiedFormat) + .toBuffer(); + + return { + buffer: sanitizedBuffer, + mimeType: FORMAT_TO_MIME_TYPE[verifiedFormat], + format: verifiedFormat, + width: metadata.width, + height: metadata.height, + }; + } catch { + throw new BadRequestError("Failed to process image file"); + } } /** @@ -87,29 +157,31 @@ export async function pinImageToIpfs( }, opts: { signal?: AbortSignal } = {}, ): Promise { - // 1. Validate file format and constraints - validateImageFile(file); + // 1. Verify the file is actually an allowed image type (by magic bytes, not + // the client-supplied MIME type) and get back a re-encoded, metadata-stripped + // copy of it — this is what gets pinned, never the original upload (issue #1232). + const sanitized = await verifyAndSanitizeImage(file); const ipfsApiUrl = config.ipfs.apiUrl; // 2. If IPFS_API_URL is unconfigured, handle fallback cleanly if (!ipfsApiUrl || !ipfsApiUrl.trim()) { logger.warn("IPFS_API_URL not configured. Using fallback CID generation mode."); - const fallbackCid = generateFallbackCid(file.buffer); + const fallbackCid = generateFallbackCid(sanitized.buffer); const gatewayUrl = buildGatewayUrl(fallbackCid); return { cid: fallbackCid, url: gatewayUrl, - size: file.size, - mimeType: file.mimetype, + size: sanitized.buffer.length, + mimeType: sanitized.mimeType, }; } // 3. Pin image via IPFS HTTP API endpoint (explicit timeout + client disconnect — issue #090) try { const formData = new globalThis.FormData(); - const blob = new globalThis.Blob([file.buffer], { type: file.mimetype }); - formData.append("file", blob, file.originalname || "image"); + const blob = new globalThis.Blob([sanitized.buffer], { type: sanitized.mimeType }); + formData.append("file", blob, file.originalname || `image.${sanitized.format}`); const endpoint = `${ipfsApiUrl.replace(/\/+$/, "")}/api/v0/add?pin=true`; const response = await fetchWithTimeout(endpoint, { @@ -129,12 +201,12 @@ export async function pinImageToIpfs( // Fallback strategy (#984): if in dev/test, fallback gracefully; in prod throw BadGatewayError if ((config as unknown as { server?: { nodeEnv: string } })?.server?.nodeEnv !== "production") { logger.warn("Non-production environment: falling back after IPFS pinning HTTP error."); - const fallbackCid = generateFallbackCid(file.buffer); + const fallbackCid = generateFallbackCid(sanitized.buffer); return { cid: fallbackCid, url: buildGatewayUrl(fallbackCid), - size: file.size, - mimeType: file.mimetype, + size: sanitized.buffer.length, + mimeType: sanitized.mimeType, }; } @@ -155,8 +227,8 @@ export async function pinImageToIpfs( return { cid, url, - size: file.size, - mimeType: file.mimetype, + size: sanitized.buffer.length, + mimeType: sanitized.mimeType, }; } catch (error) { if (error instanceof BadRequestError || error instanceof BadGatewayError) { @@ -167,8 +239,8 @@ export async function pinImageToIpfs( if (error instanceof DOMException && error.name === "TimeoutError") { logger.warn({ endpoint: `${ipfsApiUrl}/api/v0/add`, timeoutMs: (config as unknown as { timeouts?: { ipfsMs: number } })?.timeouts?.ipfsMs ?? 15_000 }, "IPFS pinning timed out"); if ((config as unknown as { server?: { nodeEnv: string } })?.server?.nodeEnv !== "production") { - const fallbackCid = generateFallbackCid(file.buffer); - return { cid: fallbackCid, url: buildGatewayUrl(fallbackCid), size: file.size, mimeType: file.mimetype }; + const fallbackCid = generateFallbackCid(sanitized.buffer); + return { cid: fallbackCid, url: buildGatewayUrl(fallbackCid), size: sanitized.buffer.length, mimeType: sanitized.mimeType }; } throw new ServiceUnavailableError(`IPFS pinning timed out after ${(config as unknown as { timeouts?: { ipfsMs: number } })?.timeouts?.ipfsMs ?? 15_000}ms`); } @@ -182,12 +254,12 @@ export async function pinImageToIpfs( // Fallback handling (#984): network exception / timeout fallback for non-prod if ((config as unknown as { server?: { nodeEnv: string } })?.server?.nodeEnv !== "production") { logger.warn("Non-production environment: fallback CID generated after IPFS failure."); - const fallbackCid = generateFallbackCid(file.buffer); + const fallbackCid = generateFallbackCid(sanitized.buffer); return { cid: fallbackCid, url: buildGatewayUrl(fallbackCid), - size: file.size, - mimeType: file.mimetype, + size: sanitized.buffer.length, + mimeType: sanitized.mimeType, }; } diff --git a/backend/src/modules/ipfs/ipfs.test.ts b/backend/src/modules/ipfs/ipfs.test.ts index 25b67a68..378754e5 100644 --- a/backend/src/modules/ipfs/ipfs.test.ts +++ b/backend/src/modules/ipfs/ipfs.test.ts @@ -1,14 +1,44 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import request from "supertest"; +import sharp from "sharp"; import { createApp } from "../../app.js"; import { config } from "../../config/index.js"; import { BadRequestError, BadGatewayError, ServiceUnavailableError } from "../../common/errors/AppError.js"; import { buildGatewayUrl, isValidCid } from "./ipfs.utils.js"; -import { validateImageFile, pinImageToIpfs, MAX_IMAGE_SIZE_BYTES } from "./ipfs.service.js"; +import { + verifyAndSanitizeImage, + pinImageToIpfs, + MAX_IMAGE_SIZE_BYTES, + MAX_IMAGE_DIMENSION_PX, +} from "./ipfs.service.js"; const VALID_CID_V0 = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"; const VALID_CID_V1 = "bafybeicn72vedxjQkDDP1mXWo6uco72vedxjQkDDP1mXWo6uco72vedxj"; +const SVG_PAYLOAD = ``; + +async function makePng(width = 8, height = 8): Promise { + return sharp({ + create: { width, height, channels: 3, background: { r: 10, g: 120, b: 200 } }, + }) + .png() + .toBuffer(); +} + +async function makeJpegWithExif(): Promise { + return sharp({ + create: { width: 8, height: 8, channels: 3, background: { r: 200, g: 50, b: 50 } }, + }) + .withMetadata({ + exif: { + IFD0: { Copyright: "Jane Doe" }, + IFD3: { GPSLatitude: "40/1,26/1,4614/100", GPSLongitude: "79/1,58/1,5541/100" }, + }, + }) + .jpeg() + .toBuffer(); +} + describe("IPFS Module", () => { const app = createApp(); @@ -50,57 +80,61 @@ describe("IPFS Module", () => { }); }); - describe("Image Upload Validation (#981)", () => { - it("should accept valid image MIME types", () => { - const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"]; - for (const mimetype of validTypes) { - expect(() => - validateImageFile({ - mimetype, - size: 1024, - buffer: Buffer.from("fake-image-bytes"), - }) - ).not.toThrow(); - } + describe("Image Upload Validation & Sanitization (#1232)", () => { + it("should accept a valid image verified by its actual magic bytes", async () => { + const buffer = await makePng(); + const result = await verifyAndSanitizeImage({ size: buffer.length, buffer }); + expect(result.format).toBe("png"); + expect(result.mimeType).toBe("image/png"); + expect(result.width).toBe(8); + expect(result.height).toBe(8); }); - it("should throw BadRequestError for missing or empty file", () => { - expect(() => validateImageFile(undefined)).toThrow(BadRequestError); - expect(() => - validateImageFile({ - mimetype: "image/png", - size: 0, - buffer: Buffer.alloc(0), - }) - ).toThrow(BadRequestError); + it("should throw BadRequestError for missing or empty file", async () => { + await expect(verifyAndSanitizeImage(undefined)).rejects.toThrow(BadRequestError); + await expect( + verifyAndSanitizeImage({ size: 0, buffer: Buffer.alloc(0) }) + ).rejects.toThrow(BadRequestError); }); - it("should throw BadRequestError for unsupported file types", () => { - expect(() => - validateImageFile({ - mimetype: "application/pdf", - size: 1024, - buffer: Buffer.from("pdf-bytes"), - }) - ).toThrow(BadRequestError); - - expect(() => - validateImageFile({ - mimetype: "text/plain", - size: 1024, - buffer: Buffer.from("text-bytes"), - }) - ).toThrow(BadRequestError); + it("should reject a file whose content-type/extension is spoofed (magic bytes don't match)", async () => { + // A plain text/PDF-like buffer masquerading as a PNG via its declared + // mimetype and filename — the client's claim is never trusted. + const buffer = Buffer.from("%PDF-1.4 not actually an image"); + await expect( + verifyAndSanitizeImage({ size: buffer.length, buffer }) + ).rejects.toThrow(BadRequestError); }); - it("should throw BadRequestError for files exceeding maximum size limit", () => { - expect(() => - validateImageFile({ - mimetype: "image/png", - size: MAX_IMAGE_SIZE_BYTES + 1, - buffer: Buffer.alloc(MAX_IMAGE_SIZE_BYTES + 1), - }) - ).toThrow(BadRequestError); + it("should reject SVG files outright, even with a valid image mimetype claim", async () => { + const buffer = Buffer.from(SVG_PAYLOAD); + await expect( + verifyAndSanitizeImage({ size: buffer.length, buffer }) + ).rejects.toThrow(BadRequestError); + }); + + it("should throw BadRequestError for files exceeding the maximum byte-size limit", async () => { + const buffer = await makePng(); + await expect( + verifyAndSanitizeImage({ size: MAX_IMAGE_SIZE_BYTES + 1, buffer }) + ).rejects.toThrow(BadRequestError); + }); + + it("should throw BadRequestError for images exceeding the maximum pixel dimensions", async () => { + const buffer = await makePng(MAX_IMAGE_DIMENSION_PX + 1, 4); + await expect( + verifyAndSanitizeImage({ size: buffer.length, buffer }) + ).rejects.toThrow(BadRequestError); + }); + + it("should strip EXIF metadata, including GPS, from the sanitized output", async () => { + const original = await makeJpegWithExif(); + const originalMeta = await sharp(original).metadata(); + expect(originalMeta.exif).toBeDefined(); + + const result = await verifyAndSanitizeImage({ size: original.length, buffer: original }); + const sanitizedMeta = await sharp(result.buffer).metadata(); + expect(sanitizedMeta.exif).toBeUndefined(); }); }); @@ -123,10 +157,11 @@ describe("IPFS Module", () => { (config.ipfs as { apiUrl?: string }).apiUrl = "http://localhost:5001"; try { + const buffer = await makePng(); const file = { mimetype: "image/png", - size: 500, - buffer: Buffer.from("sample-image-content"), + size: buffer.length, + buffer, originalname: "test.png", }; @@ -143,9 +178,9 @@ describe("IPFS Module", () => { (config.ipfs as { apiUrl?: string }).apiUrl = ""; try { - const buffer = Buffer.from("test-fallback-data"); + const buffer = await makePng(); const file = { - mimetype: "image/jpeg", + mimetype: "image/png", size: buffer.length, buffer, }; @@ -171,10 +206,11 @@ describe("IPFS Module", () => { (config.ipfs as { apiUrl?: string }).apiUrl = "http://localhost:5001"; try { + const buffer = await makePng(); const file = { mimetype: "image/png", - size: 100, - buffer: Buffer.from("test-data"), + size: buffer.length, + buffer, }; const result = await pinImageToIpfs(file); @@ -200,10 +236,11 @@ describe("IPFS Module", () => { (config.server as { nodeEnv: string }).nodeEnv = "production"; try { + const buffer = await makePng(); const file = { mimetype: "image/png", - size: 100, - buffer: Buffer.from("test-prod-data"), + size: buffer.length, + buffer, }; await expect(pinImageToIpfs(file)).rejects.toThrow(BadGatewayError); @@ -223,10 +260,11 @@ describe("IPFS Module", () => { (config.server as { nodeEnv: string }).nodeEnv = "production"; try { + const buffer = await makePng(); const file = { mimetype: "image/png", - size: 100, - buffer: Buffer.from("test-net-err"), + size: buffer.length, + buffer, }; await expect(pinImageToIpfs(file)).rejects.toThrow(ServiceUnavailableError); @@ -237,11 +275,12 @@ describe("IPFS Module", () => { }); }); - describe("HTTP Routes Integration (#981, #983, #985)", () => { + describe("HTTP Routes Integration (#981, #983, #985, #1232)", () => { it("POST /api/v1/ipfs/upload should successfully process valid image file", async () => { + const buffer = await makePng(); const response = await request(app) .post("/api/v1/ipfs/upload") - .attach("file", Buffer.from("fake-png-data"), { + .attach("file", buffer, { filename: "avatar.png", contentType: "image/png", }); @@ -253,11 +292,12 @@ describe("IPFS Module", () => { }); it("POST /api/v1/ipfs/upload should accept 'image' field name", async () => { + const buffer = await makePng(); const response = await request(app) .post("/api/v1/ipfs/upload") - .attach("image", Buffer.from("fake-jpeg-data"), { - filename: "profile.jpeg", - contentType: "image/jpeg", + .attach("image", buffer, { + filename: "profile.png", + contentType: "image/png", }); expect(response.status).toBe(201); @@ -284,6 +324,30 @@ describe("IPFS Module", () => { expect(response.body.error).toBeDefined(); }); + it("POST /api/v1/ipfs/upload should return 400 Bad Request for a spoofed content-type (fake bytes claiming image/png)", async () => { + const response = await request(app) + .post("/api/v1/ipfs/upload") + .attach("file", Buffer.from("this is not real image data"), { + filename: "avatar.png", + contentType: "image/png", + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBeDefined(); + }); + + it("POST /api/v1/ipfs/upload should return 400 Bad Request for SVG uploads", async () => { + const response = await request(app) + .post("/api/v1/ipfs/upload") + .attach("file", Buffer.from(SVG_PAYLOAD), { + filename: "logo.svg", + contentType: "image/svg+xml", + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBeDefined(); + }); + it("GET /api/v1/ipfs/gateway/:cid should return resolvable gateway URL for valid CID", async () => { const response = await request(app).get(`/api/v1/ipfs/gateway/${VALID_CID_V0}`); diff --git a/backend/src/modules/leaderboard/leaderboard.schema.ts b/backend/src/modules/leaderboard/leaderboard.schema.ts index 87ee869a..9865c295 100644 --- a/backend/src/modules/leaderboard/leaderboard.schema.ts +++ b/backend/src/modules/leaderboard/leaderboard.schema.ts @@ -4,11 +4,11 @@ export const leaderboardQuerySchema = z.object({ window: z.enum(['24h', '7d', 'all']).default('all'), limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0), -}); +}).strict(); export const userIdParamSchema = z.object({ userId: z.string().min(1), -}); +}).strict(); export const snapshotPeriodSchema = z.enum(['WEEKLY', 'MONTHLY', 'ALL_TIME']); diff --git a/backend/src/modules/moderation/moderation.schema.ts b/backend/src/modules/moderation/moderation.schema.ts index d4c53128..73529183 100644 --- a/backend/src/modules/moderation/moderation.schema.ts +++ b/backend/src/modules/moderation/moderation.schema.ts @@ -5,6 +5,6 @@ export const createModerationReportSchema = z.object({ targetId: z.string().min(1).max(160), reason: z.enum(['spam', 'harassment', 'impersonation', 'fraud', 'illegal_content', 'other']), details: z.string().max(2_000).optional(), -}); +}).strict(); export type CreateModerationReportInput = z.infer; diff --git a/backend/src/modules/moderation/moderation.service.ts b/backend/src/modules/moderation/moderation.service.ts index 84d59c0b..de552bb1 100644 --- a/backend/src/modules/moderation/moderation.service.ts +++ b/backend/src/modules/moderation/moderation.service.ts @@ -12,7 +12,10 @@ export async function createModerationReport( action: 'moderation.report.created', target: `${input.targetType}:${input.targetId}`, metadata: { - ...input, + targetType: input.targetType, + targetId: input.targetId, + reason: input.reason, + details: input.details, reporterId, } as Prisma.InputJsonValue, }, diff --git a/backend/src/modules/notifications/notifications.schema.ts b/backend/src/modules/notifications/notifications.schema.ts index bebe71ff..7556d1d5 100644 --- a/backend/src/modules/notifications/notifications.schema.ts +++ b/backend/src/modules/notifications/notifications.schema.ts @@ -8,13 +8,13 @@ export const notificationsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), cursor: z.string().min(1, 'Invalid cursor').optional(), offset: z.coerce.number().int().min(0).optional(), -}).refine((query) => query.cursor === undefined || query.offset === undefined, { +}).strict().refine((query) => query.cursor === undefined || query.offset === undefined, { message: 'cursor and offset cannot be used together', }); export const notificationIdParamSchema = z.object({ id: z.string().min(1), -}); +}).strict(); export const updateNotificationPreferencesSchema = z .object({ @@ -22,6 +22,7 @@ export const updateNotificationPreferencesSchema = z goalReached: z.boolean().optional(), subscriptionCharged: z.boolean().optional(), }) + .strict() .refine((data) => Object.keys(data).length > 0, { message: 'At least one preference must be provided', }); diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 523b3242..74de034c 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -162,8 +162,17 @@ export async function updatePreferences( ): Promise { const pref = await prisma.notificationPreference.upsert({ where: { userId }, - create: { userId, ...patch }, - update: patch, + create: { + userId, + tipReceived: patch.tipReceived, + goalReached: patch.goalReached, + subscriptionCharged: patch.subscriptionCharged, + }, + update: { + tipReceived: patch.tipReceived, + goalReached: patch.goalReached, + subscriptionCharged: patch.subscriptionCharged, + }, }); return formatPreferences(pref); } diff --git a/backend/src/modules/profiles/profiles.controller.ts b/backend/src/modules/profiles/profiles.controller.ts index a52fd1ae..ab9271bc 100644 --- a/backend/src/modules/profiles/profiles.controller.ts +++ b/backend/src/modules/profiles/profiles.controller.ts @@ -16,6 +16,7 @@ import { updateProfileSchema, profileIdSchema, usernameSchema, + uploadProfileImageSchema, } from "./profiles.schema.js"; import type { AuthPayload } from "../auth/auth.types.js"; @@ -188,9 +189,7 @@ export async function uploadImageController( ) { try { const auth = req.auth as AuthPayload; - const { dataUrl } = z.object({ - dataUrl: z.string().startsWith("data:"), - }).parse(req.body); + const { dataUrl } = uploadProfileImageSchema.parse(req.body); const result = await uploadProfileImage(auth.userId, dataUrl); res.json({ data: result }); } catch (error) { diff --git a/backend/src/modules/profiles/profiles.schema.ts b/backend/src/modules/profiles/profiles.schema.ts index 320a2b02..25f562de 100644 --- a/backend/src/modules/profiles/profiles.schema.ts +++ b/backend/src/modules/profiles/profiles.schema.ts @@ -21,16 +21,21 @@ export const updateProfileSchema = z.object({ .max(15) .regex(/^[a-zA-Z0-9_]+$/) .optional(), -}); +}).strict(); export const profileIdSchema = z.object({ id: z.string().min(1), -}); +}).strict(); export const usernameSchema = z.object({ username: z.string().min(1), -}); +}).strict(); + +export const uploadProfileImageSchema = z.object({ + dataUrl: z.string().startsWith("data:"), +}).strict(); export type UpdateProfileInput = z.infer; export type ProfileIdInput = z.infer; export type UsernameInput = z.infer; +export type UploadProfileImageInput = z.infer; diff --git a/backend/src/modules/refunds/refunds.schema.ts b/backend/src/modules/refunds/refunds.schema.ts index 29af9702..63ac15af 100644 --- a/backend/src/modules/refunds/refunds.schema.ts +++ b/backend/src/modules/refunds/refunds.schema.ts @@ -3,28 +3,28 @@ import { z } from 'zod'; export const requestRefundSchema = z.object({ tipTxHash: z.string().min(1, 'Tip transaction hash is required'), reason: z.string().min(1, 'Reason is required').max(500, 'Reason must be 500 characters or fewer'), -}); +}).strict(); export const refundHistoryQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), cursor: z.string().min(1, 'Invalid cursor').optional(), offset: z.coerce.number().int().min(0).optional(), -}).refine((query) => query.cursor === undefined || query.offset === undefined, { +}).strict().refine((query) => query.cursor === undefined || query.offset === undefined, { message: 'cursor and offset cannot be used together', }); export const refundIdParamSchema = z.object({ id: z.string().min(1, 'Refund id is required'), -}); +}).strict(); export const rejectRefundSchema = z.object({ reason: z.string().min(1, 'Rejection reason is required').max(500, 'Reason must be 500 characters or fewer'), -}); +}).strict(); export const submitRefundResolutionSchema = z.object({ signedTxXdr: z.string().min(1, 'Signed transaction XDR is required'), reason: z.string().min(1).max(500).optional(), -}); +}).strict(); export type RequestRefundInput = z.infer; export type RefundHistoryQuery = z.infer; diff --git a/backend/src/modules/search/search.schema.ts b/backend/src/modules/search/search.schema.ts index b49d8e03..9ddb97db 100644 --- a/backend/src/modules/search/search.schema.ts +++ b/backend/src/modules/search/search.schema.ts @@ -6,7 +6,7 @@ export const searchCreatorsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0), sort: z.enum(['relevance', 'recent', 'popular']).default('relevance'), -}); +}).strict(); export type SearchCreatorsQuery = z.infer; @@ -16,6 +16,6 @@ export type SearchSort = SearchCreatorsQuery['sort']; export const getTrendingCreatorsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0), -}); +}).strict(); export type GetTrendingCreatorsQuery = z.infer; diff --git a/backend/src/modules/streaks/streaks.schema.ts b/backend/src/modules/streaks/streaks.schema.ts index 592d2bba..056696c3 100644 --- a/backend/src/modules/streaks/streaks.schema.ts +++ b/backend/src/modules/streaks/streaks.schema.ts @@ -2,6 +2,6 @@ import { z } from 'zod'; export const getStreakQuerySchema = z.object({ userId: z.string().optional(), -}); +}).strict(); export type GetStreakQuery = z.infer; diff --git a/backend/src/modules/subscriptions/subscriptions.schema.ts b/backend/src/modules/subscriptions/subscriptions.schema.ts index ac88f5a5..d398360c 100644 --- a/backend/src/modules/subscriptions/subscriptions.schema.ts +++ b/backend/src/modules/subscriptions/subscriptions.schema.ts @@ -10,7 +10,7 @@ export const listSubscriptionsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), cursor: z.string().min(1, 'Invalid cursor').optional(), offset: z.coerce.number().int().min(0).optional(), -}).refine((query) => query.cursor === undefined || query.offset === undefined, { +}).strict().refine((query) => query.cursor === undefined || query.offset === undefined, { message: 'cursor and offset cannot be used together', }); @@ -18,19 +18,19 @@ export const prepareCreateSubscriptionSchema = z.object({ creatorStellarAddress: stellarAddress, amountStroops: z.string().regex(/^\d+$/, 'Amount must be a string of digits (stroops)'), interval: z.enum(['DAILY', 'WEEKLY', 'MONTHLY']), -}); +}).strict(); export const submitCreateSubscriptionSchema = prepareCreateSubscriptionSchema.extend({ signedTxXdr: z.string().min(1, 'Signed transaction XDR is required'), -}); +}).strict(); export const prepareCancelSubscriptionSchema = z.object({ creatorStellarAddress: stellarAddress, -}); +}).strict(); export const submitCancelSubscriptionSchema = prepareCancelSubscriptionSchema.extend({ signedTxXdr: z.string().min(1, 'Signed transaction XDR is required'), -}); +}).strict(); export type ListSubscriptionsQuery = z.infer; export type PrepareCreateSubscriptionInput = z.infer; diff --git a/backend/src/modules/tips/tips.schema.ts b/backend/src/modules/tips/tips.schema.ts index 866f95d3..4eceac82 100644 --- a/backend/src/modules/tips/tips.schema.ts +++ b/backend/src/modules/tips/tips.schema.ts @@ -10,7 +10,7 @@ export const tipMessageSchema = z export const submitTipSchema = z.object({ signedTxXdr: z.string().min(1, 'Signed transaction XDR is required'), -}); +}).strict(); export type SubmitTipInput = z.infer; @@ -26,21 +26,21 @@ export const prepareTipSchema = z.object({ to: z.string().regex(/^G[A-Z2-7]{55}$/, 'Invalid recipient Stellar address'), amount: z.string().regex(/^\d+$/, 'Amount must be a string of digits (stroops)'), message: tipMessageSchema, -}); +}).strict(); export type PrepareTipInput = z.infer; /** Path params for `GET /tips/:id`. */ export const tipIdParamSchema = z.object({ id: z.string().cuid('Invalid tip id'), -}); +}).strict(); export type TipIdParam = z.infer; /** Path params for `GET /profiles/:username/tips`. */ export const usernameParamSchema = z.object({ username: z.string().min(1, 'Username is required').max(50), -}); +}).strict(); export type UsernameParam = z.infer; @@ -52,7 +52,7 @@ export const tipsListQuerySchema = z.object({ tokenCode: z.string().max(10).optional(), startDate: z.string().datetime('Invalid start date (must be ISO 8601)').optional(), endDate: z.string().datetime('Invalid end date (must be ISO 8601)').optional(), -}).refine((query) => query.cursor === undefined || query.offset === undefined, { +}).strict().refine((query) => query.cursor === undefined || query.offset === undefined, { message: 'cursor and offset cannot be used together', }); @@ -69,7 +69,7 @@ export const getTipsQuerySchema = z.object({ startDate: z.string().datetime('Invalid start date (must be ISO 8601)').optional(), endDate: z.string().datetime('Invalid end date (must be ISO 8601)').optional(), aggregate: z.enum(['creator']).optional(), -}).refine((query) => query.cursor === undefined || query.offset === undefined, { +}).strict().refine((query) => query.cursor === undefined || query.offset === undefined, { message: 'cursor and offset cannot be used together', }); @@ -83,20 +83,20 @@ export const recordTipSchema = z.object({ toAddress: z.string().regex(/^G[A-Z2-7]{55}$/, 'Invalid recipient Stellar address'), amountStroops: z.string().regex(/^\d+$/, 'Amount must be a string of digits (stroops)'), message: z.string().max(280).optional(), -}); +}).strict(); export type RecordTipInput = z.infer; /** Path params for `PATCH /tips/:txHash/confirm`. */ export const confirmTipParamSchema = z.object({ txHash: z.string().min(1, 'txHash is required'), -}); +}).strict(); export type ConfirmTipParam = z.infer; /** Path params for `GET /tips/:txHash/receipt`. */ export const receiptParamSchema = z.object({ txHash: z.string().min(1, 'txHash is required'), -}); +}).strict(); export type ReceiptParam = z.infer; diff --git a/backend/src/modules/webhooks/webhooks.schema.ts b/backend/src/modules/webhooks/webhooks.schema.ts index 1cc0c006..100d8b2f 100644 --- a/backend/src/modules/webhooks/webhooks.schema.ts +++ b/backend/src/modules/webhooks/webhooks.schema.ts @@ -20,16 +20,16 @@ export const createWebhookSubscriptionSchema = z.object({ events: z .array(z.enum(WEBHOOK_EVENT_TYPES)) .min(1, "At least one event is required"), -}); +}).strict(); export const listWebhookSubscriptionsQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), -}); +}).strict(); export const webhookSubscriptionIdParamSchema = z.object({ id: z.string().min(1, "Webhook subscription ID is required"), -}); +}).strict(); export type CreateWebhookSubscriptionInput = z.infer; export type ListWebhookSubscriptionsQuery = z.infer; @@ -40,11 +40,11 @@ export const deliveryQuerySchema = z.object({ status: z.enum(["PENDING", "SUCCESS", "FAILED"]).optional(), page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), -}); +}).strict(); export const deliveryIdParamSchema = z.object({ id: z.string().min(1, "Delivery ID is required"), -}); +}).strict(); export type DeliveryQuery = z.infer; export type DeliveryIdParam = z.infer; diff --git a/backend/src/modules/withdrawals/payouts.schema.ts b/backend/src/modules/withdrawals/payouts.schema.ts index 95f3a84e..15e9597f 100644 --- a/backend/src/modules/withdrawals/payouts.schema.ts +++ b/backend/src/modules/withdrawals/payouts.schema.ts @@ -7,6 +7,6 @@ export const payoutScheduleSchema = z.object({ .regex(/^\d+$/, 'Must be a non-negative integer string (stroops)') .optional(), cadence: z.enum(['MANUAL', 'DAILY', 'WEEKLY', 'MONTHLY']).optional(), -}); +}).strict(); export type PayoutScheduleInput = z.infer; diff --git a/backend/src/modules/withdrawals/withdrawals.schema.ts b/backend/src/modules/withdrawals/withdrawals.schema.ts index cc6e400d..9998fe2b 100644 --- a/backend/src/modules/withdrawals/withdrawals.schema.ts +++ b/backend/src/modules/withdrawals/withdrawals.schema.ts @@ -4,18 +4,18 @@ export const withdrawalHistoryQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), cursor: z.string().min(1, 'Invalid cursor').optional(), offset: z.coerce.number().int().min(0).optional(), -}).refine((query) => query.cursor === undefined || query.offset === undefined, { +}).strict().refine((query) => query.cursor === undefined || query.offset === undefined, { message: 'cursor and offset cannot be used together', }); export const prepareWithdrawalSchema = z.object({ amount: z.string().regex(/^\d+$/, 'Amount must be a string of digits (stroops)'), -}); +}).strict(); export const submitWithdrawalSchema = z.object({ amount: z.string().regex(/^\d+$/, 'Amount must be a string of digits (stroops)'), signedTxXdr: z.string().min(1, 'Signed transaction XDR is required'), -}); +}).strict(); export type WithdrawalHistoryQuery = z.infer; export type PrepareWithdrawalInput = z.infer; diff --git a/backend/src/modules/x/x.schema.ts b/backend/src/modules/x/x.schema.ts index 38a2a0c7..c0619fe4 100644 --- a/backend/src/modules/x/x.schema.ts +++ b/backend/src/modules/x/x.schema.ts @@ -13,7 +13,7 @@ export const xHandleSchema = z.object({ /^[a-zA-Z0-9_]+$/, "X handle must contain only letters, numbers, and underscores", ), -}); +}).strict(); export const fetchMetricsSchema = z.object({ handle: z @@ -29,7 +29,7 @@ export const fetchMetricsSchema = z.object({ .number() .optional() .default(24 * 60 * 60 * 1000), // 24 hours -}); +}).strict(); export type XHandleInput = z.infer; export type FetchMetricsInput = z.infer; diff --git a/backend/tests/zodStrictValidation.test.ts b/backend/tests/zodStrictValidation.test.ts new file mode 100644 index 00000000..106226dc --- /dev/null +++ b/backend/tests/zodStrictValidation.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { createAuditLogSchema } from '../src/modules/admin/admin.schema.js'; +import { + prepareSetFeeSchema, + prepareSetMinTipAmountSchema, + prepareSetMinWithdrawalAmountSchema, + preparePauseSchema, + submitSetFeeSchema, + submitSetMinTipAmountSchema, + submitSetMinWithdrawalAmountSchema, + submitPauseSchema, +} from '../src/modules/admin/config.schema.js'; +import { createApiKeySchema, rotateApiKeySchema } from '../src/modules/apiKeys/apiKeys.schema.js'; +import { challengeSchema, verifySchema, refreshSchema } from '../src/modules/auth/auth.schema.js'; +import { recalculateSchema } from '../src/modules/credit/credit.schema.js'; +import { sendEmailSchema } from '../src/modules/email/email.schema.js'; +import { createGoalSchema, updateGoalSchema } from '../src/modules/goals/goals.schema.js'; +import { createModerationReportSchema } from '../src/modules/moderation/moderation.schema.js'; +import { updateNotificationPreferencesSchema } from '../src/modules/notifications/notifications.schema.js'; +import { updateProfileSchema, uploadProfileImageSchema } from '../src/modules/profiles/profiles.schema.js'; +import { + requestRefundSchema, + rejectRefundSchema, + submitRefundResolutionSchema, +} from '../src/modules/refunds/refunds.schema.js'; +import { + prepareCreateSubscriptionSchema, + submitCreateSubscriptionSchema, + prepareCancelSubscriptionSchema, + submitCancelSubscriptionSchema, +} from '../src/modules/subscriptions/subscriptions.schema.js'; +import { + submitTipSchema, + prepareTipSchema, + recordTipSchema, +} from '../src/modules/tips/tips.schema.js'; +import { createWebhookSubscriptionSchema } from '../src/modules/webhooks/webhooks.schema.js'; +import { payoutScheduleSchema } from '../src/modules/withdrawals/payouts.schema.js'; +import { + prepareWithdrawalSchema, + submitWithdrawalSchema, +} from '../src/modules/withdrawals/withdrawals.schema.js'; +import { xHandleSchema, fetchMetricsSchema } from '../src/modules/x/x.schema.js'; +import { cidParamSchema } from '../src/modules/ipfs/ipfs.schema.js'; + +const STELLAR_ADDRESS = `G${'A'.repeat(55)}`; + +/** + * Every request-body schema used to parse a mutating endpoint's `req.body` + * (or, for IPFS, `req.params`), paired with a minimal payload that is valid + * against the schema *before* an `extra` field is appended (issue #1233). + */ +const strictSchemas: Array<{ + name: string; + schema: z.ZodTypeAny; + validPayload: Record; +}> = [ + { name: 'admin.createAuditLogSchema', schema: createAuditLogSchema, validPayload: { action: 'test.action' } }, + { name: 'config.prepareSetFeeSchema', schema: prepareSetFeeSchema, validPayload: { feeBps: 100 } }, + { name: 'config.prepareSetMinTipAmountSchema', schema: prepareSetMinTipAmountSchema, validPayload: { amount: '100' } }, + { name: 'config.prepareSetMinWithdrawalAmountSchema', schema: prepareSetMinWithdrawalAmountSchema, validPayload: { amount: '100' } }, + { name: 'config.preparePauseSchema', schema: preparePauseSchema, validPayload: { paused: true } }, + { name: 'config.submitSetFeeSchema', schema: submitSetFeeSchema, validPayload: { feeBps: 100, signedTxXdr: 'xdr' } }, + { name: 'config.submitSetMinTipAmountSchema', schema: submitSetMinTipAmountSchema, validPayload: { amount: '100', signedTxXdr: 'xdr' } }, + { name: 'config.submitSetMinWithdrawalAmountSchema', schema: submitSetMinWithdrawalAmountSchema, validPayload: { amount: '100', signedTxXdr: 'xdr' } }, + { name: 'config.submitPauseSchema', schema: submitPauseSchema, validPayload: { paused: true, signedTxXdr: 'xdr' } }, + { name: 'apiKeys.createApiKeySchema', schema: createApiKeySchema, validPayload: { scopes: ['read'] } }, + { name: 'apiKeys.rotateApiKeySchema', schema: rotateApiKeySchema, validPayload: { gracePeriodMinutes: 60 } }, + { name: 'auth.challengeSchema', schema: challengeSchema, validPayload: { stellarAddress: STELLAR_ADDRESS } }, + { name: 'auth.verifySchema', schema: verifySchema, validPayload: { stellarAddress: STELLAR_ADDRESS, signature: 'sig', challenge: 'chal' } }, + { name: 'auth.refreshSchema', schema: refreshSchema, validPayload: { refreshToken: 'token' } }, + { name: 'credit.recalculateSchema', schema: recalculateSchema, validPayload: { userId: 'user-1' } }, + { name: 'email.sendEmailSchema', schema: sendEmailSchema, validPayload: { to: 'a@example.com', subject: 'Hi', text: 'Body' } }, + { name: 'goals.createGoalSchema', schema: createGoalSchema, validPayload: { title: 'Goal', targetStroops: '1000' } }, + { name: 'goals.updateGoalSchema', schema: updateGoalSchema, validPayload: { title: 'Goal' } }, + { name: 'ipfs.cidParamSchema', schema: cidParamSchema, validPayload: { cid: 'QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco' } }, + { name: 'moderation.createModerationReportSchema', schema: createModerationReportSchema, validPayload: { targetType: 'profile', targetId: 'p1', reason: 'spam' } }, + { name: 'notifications.updateNotificationPreferencesSchema', schema: updateNotificationPreferencesSchema, validPayload: { tipReceived: true } }, + { name: 'profiles.updateProfileSchema', schema: updateProfileSchema, validPayload: { displayName: 'Name' } }, + { name: 'profiles.uploadProfileImageSchema', schema: uploadProfileImageSchema, validPayload: { dataUrl: 'data:image/png;base64,AAAA' } }, + { name: 'refunds.requestRefundSchema', schema: requestRefundSchema, validPayload: { tipTxHash: 'hash', reason: 'reason' } }, + { name: 'refunds.rejectRefundSchema', schema: rejectRefundSchema, validPayload: { reason: 'reason' } }, + { name: 'refunds.submitRefundResolutionSchema', schema: submitRefundResolutionSchema, validPayload: { signedTxXdr: 'xdr' } }, + { name: 'subscriptions.prepareCreateSubscriptionSchema', schema: prepareCreateSubscriptionSchema, validPayload: { creatorStellarAddress: STELLAR_ADDRESS, amountStroops: '100', interval: 'DAILY' } }, + { name: 'subscriptions.submitCreateSubscriptionSchema', schema: submitCreateSubscriptionSchema, validPayload: { creatorStellarAddress: STELLAR_ADDRESS, amountStroops: '100', interval: 'DAILY', signedTxXdr: 'xdr' } }, + { name: 'subscriptions.prepareCancelSubscriptionSchema', schema: prepareCancelSubscriptionSchema, validPayload: { creatorStellarAddress: STELLAR_ADDRESS } }, + { name: 'subscriptions.submitCancelSubscriptionSchema', schema: submitCancelSubscriptionSchema, validPayload: { creatorStellarAddress: STELLAR_ADDRESS, signedTxXdr: 'xdr' } }, + { name: 'tips.submitTipSchema', schema: submitTipSchema, validPayload: { signedTxXdr: 'xdr' } }, + { name: 'tips.prepareTipSchema', schema: prepareTipSchema, validPayload: { from: STELLAR_ADDRESS, to: STELLAR_ADDRESS, amount: '100' } }, + { name: 'tips.recordTipSchema', schema: recordTipSchema, validPayload: { txHash: 'hash', ledger: 1, fromAddress: STELLAR_ADDRESS, toAddress: STELLAR_ADDRESS, amountStroops: '100' } }, + { name: 'webhooks.createWebhookSubscriptionSchema', schema: createWebhookSubscriptionSchema, validPayload: { url: 'https://example.com/hook', events: ['tip.received'] } }, + { name: 'payouts.payoutScheduleSchema', schema: payoutScheduleSchema, validPayload: { enabled: true } }, + { name: 'withdrawals.prepareWithdrawalSchema', schema: prepareWithdrawalSchema, validPayload: { amount: '100' } }, + { name: 'withdrawals.submitWithdrawalSchema', schema: submitWithdrawalSchema, validPayload: { amount: '100', signedTxXdr: 'xdr' } }, + { name: 'x.xHandleSchema', schema: xHandleSchema, validPayload: { handle: 'jack' } }, + { name: 'x.fetchMetricsSchema', schema: fetchMetricsSchema, validPayload: { handle: 'jack' } }, +]; + +describe('Zod strict-mode validation on request schemas (issue #1233)', () => { + it('has at least one schema under test', () => { + expect(strictSchemas.length).toBeGreaterThan(0); + }); + + for (const { name, schema, validPayload } of strictSchemas) { + describe(name, () => { + it('accepts the known, valid fields', () => { + expect(() => schema.parse(validPayload)).not.toThrow(); + }); + + it('rejects an unrecognized field with a message naming it', () => { + const withExtra = { ...validPayload, notAFieldOnThisSchema: 'surprise' }; + const result = schema.safeParse(withExtra); + expect(result.success).toBe(false); + if (!result.success) { + const message = JSON.stringify(result.error.issues); + expect(message).toContain('notAFieldOnThisSchema'); + } + }); + }); + } +});