{"record":{"id":"e9df7732384f7370","repo":"paperclipai/paperclip","slug":"image-bytes-do-not-match-the-declared-content-type","errorCode":null,"errorMessage":"Image bytes do not match the declared content type","messagePattern":"Image bytes do not match the declared content type","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"server/src/services/photon/media.ts","lineNumber":97,"sourceCode":"  const metadata = await sharp(body, {\n    limitInputPixels: MAX_PIXELS,\n    failOn: \"error\",\n  }).metadata();\n  if (\n    !metadata.width ||\n    !metadata.height ||\n    metadata.width * metadata.height * (metadata.pages ?? 1) > MAX_PIXELS\n  )\n    throw new Error(\"Decoded image exceeds the pixel limit\");\n  const formats: Record<string, string[]> = {\n    \"image/jpeg\": [\"jpeg\"],\n    \"image/jpg\": [\"jpeg\"],\n    \"image/png\": [\"png\"],\n    \"image/webp\": [\"webp\"],\n    \"image/gif\": [\"gif\"],\n  };\n  if (!formats[contentType]?.includes(metadata.format ?? \"\"))\n    throw new Error(\"Image bytes do not match the declared content type\");\n}\n\n/** Isolate the native converter with a deadline and bounded input/output.\n * Native packages exist for macOS, Windows and Linux glibc x64/arm64.\n * Unsupported hosts retain the original and report an unavailable preview. */\nexport async function photonHeifPreview(body: Buffer): Promise<Buffer> {\n  validateHeifDimensions(body);\n  const modulePath = require.resolve(\"heif2jpeg\");\n  const script = `const {heifToJpeg}=require(process.argv[1]);const chunks=[];process.stdin.on('data',c=>chunks.push(c));process.stdin.on('end',async()=>{try{const jpeg=await heifToJpeg(Buffer.concat(chunks),{quality:80});process.stdout.end(jpeg);}catch{process.exitCode=1;}});`;\n  const jpeg = await new Promise<Buffer>((resolve, reject) => {\n    const child = spawn(\n      process.execPath,\n      [\"--max-old-space-size=256\", \"--eval\", script, modulePath],\n      { stdio: [\"pipe\", \"pipe\", \"ignore\"], windowsHide: true },\n    );\n    let length = 0;\n    const chunks: Buffer[] = [];\n    const timer = setTimeout(() => {","sourceCodeStart":79,"sourceCodeEnd":115,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/server/src/services/photon/media.ts#L79-L115","documentation":"validatePhotonImage compares sharp's detected metadata.format against an allowlist mapping each declared content type to its expected format(s) (jpeg/png/webp/gif). It throws when the decoded bytes' actual format is not in the list for the declared content type — i.e. the Content-Type header lies about the payload.","triggerScenarios":"Calling validatePhotonImage with e.g. contentType 'image/png' on bytes sharp detects as jpeg; declaring image/jpeg on WebP bytes; any declared type not present in the formats map (image/avif, image/tiff, image/svg+xml on non-HEIF path) so formats[contentType] is undefined.","commonSituations":"File-manager exports mislabeling types; upload clients deriving Content-Type from file extension; servers forwarding upstream Content-Type from third-party fetches; AVIF/ TIFF uploads declared as supported types.","solutions":["Detect the true type from magic bytes (e.g. file-type package) and call validatePhotonImage with the detected type instead of the client-supplied header.","Have the client send the correct Content-Type (set from actual encoding, not filename extension).","If the format should be supported (e.g. image/avif), add it to the formats map in media.ts: formats[\"image/avif\"] = [\"avif\"].","Reject unsupported/lying types with 415 Unsupported Media Type at the ingest boundary."],"exampleFix":"// before\nawait validatePhotonImage(body, req.headers['content-type']); // header says image/png, bytes are jpeg\n// after\nconst { fileTypeFromBuffer } = await import('file-type');\nconst detected = await fileTypeFromBuffer(body);\nawait validatePhotonImage(body, `image/${detected.ext}`);","handlingStrategy":"validation","validationCode":"import { fileTypeFromBuffer } from 'file-type';\nexport async function contentTypeMatchesBytes(body: Buffer, declared: string): Promise<boolean> {\n  const detected = await fileTypeFromBuffer(body);\n  if (!detected) return false;\n  const formats: Record<string, string[]> = {\n    'image/jpeg': ['jpeg'], 'image/jpg': ['jpeg'], 'image/png': ['png'],\n    'image/webp': ['webp'], 'image/gif': ['gif'],\n  };\n  return formats[declared]?.includes(detected.ext) ?? false;\n}","typeGuard":"function isKnownRasterType(ct: string): ct is 'image/jpeg' | 'image/jpg' | 'image/png' | 'image/webp' | 'image/gif' {\n  return ['image/jpeg','image/jpg','image/png','image/webp','image/gif'].includes(ct);\n}","tryCatchPattern":"try {\n  await validatePhotonImage(body, declaredType);\n} catch (err) {\n  if (err instanceof Error && err.message === 'Image bytes do not match the declared content type') {\n    const detected = await fileTypeFromBuffer(body);\n    if (detected) return validatePhotonImage(body, `image/${detected.ext}`);\n    return respond(415, 'unsupported or mislabeled image type');\n  }\n  throw err;\n}","preventionTips":["Never trust client-supplied Content-Type; sniff magic bytes server-side","Set Content-Type from actual encoding in upload clients, not filename extension","Only advertise formats present in the formats map (jpeg/png/webp/gif plus HEIF set)","Return 415 for genuinely unsupported formats instead of routing them through the raster validator"],"tags":["image","content-type-mismatch","validation","sharp"],"backgroundTag":"unexpected-response-shape","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}