diff --git a/package-lock.json b/package-lock.json index 6da01270..e65478d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "nextjs-boilerplate-1", + "name": "doubtdesk", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nextjs-boilerplate-1", + "name": "doubtdesk", "version": "0.1.0", "dependencies": { "@clerk/nextjs": "^6.39.3", @@ -54,6 +54,7 @@ "drizzle-orm": "^0.45.2", "embla-carousel-react": "8.6.0", "file-saver": "2.0.5", + "file-type": "^22.0.1", "framer-motion": "^12.40.0", "google-tts-api": "^0.0.6", "groq-sdk": "1.1.1", @@ -1923,6 +1924,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", @@ -9169,6 +9180,29 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@traceloop/ai-semantic-conventions": { "version": "0.20.0", "resolved": "https://registry.npmjs.org/@traceloop/ai-semantic-conventions/-/ai-semantic-conventions-0.20.0.tgz", @@ -14582,6 +14616,24 @@ "node": ">= 12" } }, + "node_modules/file-type": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -15794,6 +15846,26 @@ "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", "license": "Apache-2.0" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -22933,6 +23005,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/style-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", @@ -23584,6 +23672,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -23923,6 +24029,18 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ulid": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.4.0.tgz", diff --git a/package.json b/package.json index d5f16f33..050d4104 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "drizzle-orm": "^0.45.2", "embla-carousel-react": "8.6.0", "file-saver": "2.0.5", + "file-type": "^22.0.1", "framer-motion": "^12.40.0", "google-tts-api": "^0.0.6", "groq-sdk": "1.1.1", diff --git a/src/app/api/ask-ai/route.ts b/src/app/api/ask-ai/route.ts index c04f4f66..2563cf5c 100644 --- a/src/app/api/ask-ai/route.ts +++ b/src/app/api/ask-ai/route.ts @@ -6,7 +6,7 @@ import { db } from "@/configs/db"; import { membershipsTable, usersTable, aiSessionsTable } from "@/configs/schema"; import { enforceApiRateLimit } from "@/lib/ratelimit/api-rate-limit"; import { aiLimiter } from "@/lib/ratelimit/ratelimit"; -import { AI_REQUEST_MAX_BYTES } from "@/lib/ai/ai-image-validation"; +import { AI_REQUEST_MAX_BYTES, validateAiImageDataUrl, type AiImageValidationResult } from "@/lib/ai/ai-image-validation"; import { buildSystemMessages } from "@/lib/ai/socratic-prompt"; import { buildErrorResponse } from "@/lib/errors/error-handler"; import type { AIMode } from "@/types/ai-chat"; @@ -65,15 +65,12 @@ export async function POST(req: Request): Promise { : ""; if (body.imageBase64 !== undefined) { - const img = body.imageBase64 as string; - const validMime = /^data:image\/(png|jpe?g|webp);base64,/.test(img); - if (!validMime) { + const result = await validateAiImageDataUrl(body.imageBase64); + if (!result.ok) { + const err = result as Extract; return NextResponse.json( - { - error: "Please upload a valid PNG, JPG, or WEBP image.", - code: "INVALID_IMAGE_PAYLOAD", - }, - { status: 422 } + { error: err.error, code: err.code }, + { status: err.status } ); } } diff --git a/src/lib/ai/ai-image-validation.ts b/src/lib/ai/ai-image-validation.ts index 2084e3b3..b065a6af 100644 --- a/src/lib/ai/ai-image-validation.ts +++ b/src/lib/ai/ai-image-validation.ts @@ -1,3 +1,5 @@ +import { fileTypeFromBuffer } from 'file-type'; + export const AI_REQUEST_MAX_BYTES = 4 * 1024 * 1024; export const AI_IMAGE_MAX_BYTES = 3 * 1024 * 1024; @@ -44,9 +46,42 @@ export function getBase64DecodedByteLength(base64Data: string) { return Math.floor((base64Data.length * 3) / 4) - padding; } -export function validateAiImageDataUrl( +/** + * Verify that the decoded base64 payload's magic bytes match the declared + * MIME type. This prevents clients from spoofing the data URI prefix while + * sending a different (potentially malicious) binary format. + */ +async function validateMagicBytes( + base64Data: string, + declaredMime: string +): Promise { + const buffer = Buffer.from(base64Data, 'base64'); + const detected = await fileTypeFromBuffer(buffer); + + if (!detected) { + return { + ok: false, + status: 422, + code: 'INVALID_IMAGE_PAYLOAD', + error: 'Cannot verify image format. Please upload a valid PNG, JPG, or WEBP image.', + }; + } + + if (detected.mime !== declaredMime) { + return { + ok: false, + status: 422, + code: 'IMAGE_MIME_MISMATCH', + error: `Declared image type (${declaredMime}) does not match actual content (${detected.mime}).`, + }; + } + + return null; +} + +export async function validateAiImageDataUrl( imageBase64: unknown -): AiImageValidationResult { +): Promise { if (typeof imageBase64 !== "string") { return { ok: false, @@ -108,6 +143,9 @@ export function validateAiImageDataUrl( }; } + const magicError = await validateMagicBytes(base64Data, mimeType); + if (magicError) return magicError; + return { ok: true, dataUrl: imageBase64,