AI image provenance checker + survivor-security roadmap - #11
Conversation
Adds /check-image: an adults-gated, privacy-preserving tool to check whether an image carries AI-origin signals. - Client-side reader (c2pa + exifr) reads C2PA Content Credentials and IPTC/EXIF tags fully in-browser; no upload. - Optional opt-in deeper check forwards the image to OpenAI's content_provenance_checks endpoint (C2PA + SynthID watermark) via a new in-memory server route that stores nothing. Guarded by CSRF, IP rate limit, adults-only gate, and explicit consent. - Detection never gates help; results are shown as facts, never verdicts. - Cloudflare for Startups credit badge on the homepage. - Product roadmap (survivor-security direction) + ADR 002 (consented, encrypted, time-limited media) with the OpenAI third-party exception. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
|
||
| const apiKey = process.env.OPENAI_API_KEY; | ||
| if (!apiKey) { | ||
| return NextResponse.json({ error: "provider_unconfigured" }, { status: 503 }); |
There was a problem hiding this comment.
CRITICAL: This route (and the whole /api/check-image deep-check feature) forwards user-submitted images to OpenAI from the server, which is exactly the capability ADR-002 says must not ship until Indian counsel, a security review, and an explicit child-protection routing protocol are in place. The only gate here is whether OPENAI_API_KEY happens to be set (line 73-76) — that's a deploy-configuration accident, not a safety gate. There is no analogous ENABLE_* flag (compare ENABLE_HASH_UPLOAD/ENABLE_PLATFORM_API) that defaults off in production. Since Vercel preview is documented as production-equivalent with full env vars set, adding OPENAI_API_KEY to .env.example/Vercel env (as this PR does) will make this feature live the moment it's configured — before the ADR's own blockers are cleared. Please add an explicit env-var gate (e.g. ENABLE_AI_PROVENANCE_DEEP_CHECK=true) that stays off in all environments until ADR-002 is accepted, per the ADR's own 'Status and blockers' section.
| // Both gates are mandatory: adults-only, and explicit consent to send to OpenAI. | ||
| if (form.get("ageConfirmed") !== "true") { | ||
| logSecurityEvent({ event: "minor_route_blocked", route: "/api/check-image", reason: "age_not_confirmed" }); | ||
| return NextResponse.json({ error: "age_not_confirmed" }, { status: 400 }); |
There was a problem hiding this comment.
CRITICAL: The 'adults-only' gate is pure client self-attestation — the server only checks that the form field ageConfirmed equals the literal string "true", and the client (ImageChecker.tsx) always sends that literal regardless of any real verification. Since this endpoint is public and unauthenticated, anyone can curl it directly with ageConfirmed=true&consent=true and any image bytes, and the server will relay that image straight to OpenAI with zero technical safeguard. Given this tool is explicitly aimed at NCII images (per the roadmap), there's a real risk of the server forwarding an image of a minor to a third party — exactly the scenario ADR-002 says needs 'an explicit protocol' before shipping, and exactly what 'minors never reach the case flow' is meant to prevent for the rest of the app. A checkbox with no server-side verification is not a minor-exclusion gate.
|
|
||
| const C2PA_VERSION = "0.30.17"; | ||
| const C2PA_WASM = `https://cdn.jsdelivr.net/npm/c2pa@${C2PA_VERSION}/dist/assets/wasm/toolkit_bg.wasm`; | ||
| const C2PA_WORKER = `https://cdn.jsdelivr.net/npm/c2pa@${C2PA_VERSION}/dist/c2pa.worker.min.js`; |
There was a problem hiding this comment.
WARNING: The client-side reader loads executable WASM and a Web Worker script from a third-party CDN (jsdelivr) with no subresource-integrity check: createC2pa({ wasmSrc: C2PA_WASM, workerSrc: C2PA_WORKER }). If jsdelivr or the c2pa npm package were ever compromised, that code would execute in a survivor's browser on this origin. Worth noting the c2pa package itself is also deprecated upstream ('use @contentauth/c2pa-web instead', per the lockfile). Consider self-hosting these assets or adding SRI hashes, and tracking migration off the deprecated package.
| } | ||
|
|
||
| const ip = getClientIp(request); | ||
| const limit = checkRateLimit(`check-image:${sha256(ip)}`, 10, 60 * 60 * 1000); |
There was a problem hiding this comment.
WARNING: This per-IP rate limit (10/hour) is the only abuse/cost control on a route that makes a paid third-party API call and forwards user content externally. checkRateLimit falls back to the in-memory Map unless RATE_LIMIT_DRIVER=redis is configured, and this codebase's own architecture notes that 'Vercel serverless functions are stateless' — so on Vercel this limit likely doesn't hold across function instances in production. Worth confirming RATE_LIMIT_DRIVER=redis is actually set wherever this route is deployed, otherwise the limit is effectively decorative for this specific endpoint.
Review summaryThis PR adds a client-side AI-provenance/C2PA reader plus an optional server-side "deeper check" that forwards the uploaded image to OpenAI's content-provenance endpoint. It also adds The client-side reader ( Critical issues (2):
Warnings (2): no subresource-integrity on the CDN-loaded WASM/worker (and the Given the core "no media, ever" safety principle and the PR's own ADR blockers, I'd hold this until the OpenAI forwarding path is behind a real off-by-default flag and gated by something stronger than a checkbox. Verdict: BLOCK |
Addresses the security review on PR #11: - /api/check-image returns 404 unless ENABLE_PROVENANCE_CHECK=true, so setting OPENAI_API_KEY alone never makes the third-party forwarding live. Flag stays off in production/preview until ADR 002 legal blockers clear. - Deeper-check UI is hidden unless the flag is on. - Documented the client-attested age-gate limitation and the serverless rate-limit caveat in ADR 002 as blockers before the flag can be enabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // itself: it does not guess, and it cannot falsely accuse a genuine photo. | ||
|
|
||
| const C2PA_VERSION = "0.30.17"; | ||
| const C2PA_WASM = `https://cdn.jsdelivr.net/npm/c2pa@${C2PA_VERSION}/dist/assets/wasm/toolkit_bg.wasm`; |
There was a problem hiding this comment.
CRITICAL: The app's CSP (asmita/next.config.ts, script-src 'self', connect-src 'self') was not updated by this PR and does not allow cdn.jsdelivr.net. That CSP applies unconditionally (not gated on NODE_ENV) to every route including /check-image, so the browser will block both the fetch for toolkit_bg.wasm (connect-src) and the c2pa.worker.min.js worker script (script-src/worker-src fallback) in every environment — dev, preview, and prod alike. Because readContentCredentials() swallows all failures in a bare try/catch ("A missing or unreadable manifest is the common case; not an error to the user"), this isn't surfaced anywhere: survivors will always see "No AI credential found" even though the C2PA check never actually ran. That's a silent failure of a feature this exact page markets as working ("What we found"). Needs either a CSP exception for the jsdelivr origin (script-src/worker-src/connect-src) or, better, self-hosting the wasm/worker assets under /self so no CSP change is needed.
| async function readContentCredentials(file: File, result: ProvenanceResult): Promise<void> { | ||
| try { | ||
| const { createC2pa } = await import("c2pa"); | ||
| const c2pa = await createC2pa({ wasmSrc: C2PA_WASM, workerSrc: C2PA_WORKER }); |
There was a problem hiding this comment.
WARNING: Separately from the CSP block above — even once jsdelivr is allow-listed, this loads executable WASM + a Web Worker script from a public CDN at runtime with no Subresource Integrity (no integrity hash passed anywhere). For a tool whose entire stated promise on this page is that the image "never leaves the device," that promise now transitively depends on jsdelivr and this specific npm publish never being compromised — a compromised worker has direct access to the raw image bytes client-side and could exfiltrate them, which would be a silent, undetectable violation of the "no media, ever" principle. Recommend self-hosting toolkit_bg.wasm and c2pa.worker.min.js from the app's own origin (also fixes the CSP issue above for free) or adding SRI if self-hosting isn't feasible.
| } | ||
|
|
||
| const ip = getClientIp(request); | ||
| const limit = checkRateLimit(`check-image:${sha256(ip)}`, 10, 60 * 60 * 1000); |
There was a problem hiding this comment.
WARNING: This is the only API route in the codebase using the synchronous, memory-only checkRateLimit — every other route (platforms/detect, cases/[caseId]/urls, cases/[caseId]/hashes, auth/request-otp, admin/auth/request-otp) uses checkRateLimitAsync. Per src/lib/rate-limit.ts, checkRateLimit throws synchronously ("Use checkRateLimitAsync when RATE_LIMIT_DRIVER=redis") whenever RATE_LIMIT_DRIVER=redis + REDIS_URL are set — which is the documented production driver. That throw isn't caught here, so if this route is ever enabled in an environment with the Redis rate-limit driver configured, every request 500s outright rather than degrading to the "doesn't hold across serverless instances" limitation already called out in ADR 002. Should be await checkRateLimitAsync(...) like the rest of the routes.
| "@types/qrcode": "^1.5.6", | ||
| "@vercel/analytics": "^2.0.1", | ||
| "bullmq": "^5.76.7", | ||
| "c2pa": "^0.30.17", |
There was a problem hiding this comment.
WARNING: package-lock.json shows this pulls in c2pa@0.30.17, which is deprecated upstream ("This package is no longer being actively developed. Please use @contentauth/c2pa-web instead."). It's the library doing content-authenticity parsing of arbitrary, attacker-influenced image files client-side — worth moving to the maintained @contentauth/c2pa-web before this ships broadly, since it won't get further security fixes.
Review summaryThis PR adds the client-side C2PA/EXIF provenance reader ( Found one CRITICAL and three WARNINGs (posted inline):
No issues found with the OpenAI dispatch path itself, the dev bypass flags, PII handling, or the minor-routing link — those all look correctly scoped and gated. |
Addresses the security review's CSP critical: the app CSP (script-src/connect-src 'self') blocked the c2pa wasm+worker loaded from jsdelivr, so the on-device Content Credentials read silently never ran. Removes the c2pa browser dependency entirely and relies on exifr (pure JS, no CDN, no WASM) to read IPTC/XMP/EXIF AI-origin tags on device. Full C2PA + SynthID verification remains available server-side via the OpenAI deeper check. Also resolves review warnings: removes the deprecated c2pa package and the CDN supply-chain/SRI concern, and switches /api/check-image to checkRateLimitAsync for consistency with every other route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // Note: age is client self-attested and cannot be verified on a public | ||
| // endpoint. When this flag is eventually enabled, the route must additionally | ||
| // be bound to an authenticated case that already passed the adult age gate. | ||
| if (process.env.ENABLE_PROVENANCE_CHECK !== "true") { |
There was a problem hiding this comment.
WARNING: The adults-only gate for this route is pure client self-attestation (form.get("ageConfirmed") === "true") on an unauthenticated public endpoint — any direct caller (curl, script) can set this to true regardless of the actual image content, and there is no server-side verification tying the request to an age-verified case. Per CLAUDE.md, "Minors never reach the case flow" is a hard invariant, and here the consequence of a bypass is worse than usual: an image is forwarded to a third party (OpenAI) if ENABLE_PROVENANCE_CHECK is ever turned on. The PR's own ADR 002 flags this as a 'known limitation to resolve before enabling,' which is good, but right now the only control preventing exploitation is the env-var gate + a documentation promise not to set it in prod/preview. Given the severity if that promise is ever broken (e.g. someone flips the flag without reading the ADR), consider a technical safeguard beyond the single env var — e.g. also require a second independent flag, or refuse to call the OpenAI branch unless running with NODE_ENV !== "production", so a single misconfigured env var in Vercel can't turn this on in a live environment.
|
|
||
| // Both gates are mandatory: adults-only, and explicit consent to send to OpenAI. | ||
| if (form.get("ageConfirmed") !== "true") { | ||
| logSecurityEvent({ event: "minor_route_blocked", route: "/api/check-image", reason: "age_not_confirmed" }); |
There was a problem hiding this comment.
WARNING: this reuses the minor_route_blocked security event, which elsewhere (src/app/api/cases/create/route.ts) means a real POCSO/minor-pathway routing decision — a legally significant signal. Here it fires for anyone who simply leaves an unchecked checkbox on an anonymous, unauthenticated tool with no actual minor detection behind it. Mixing high-signal minor-routing events with high-noise 'forgot to tick a box' events in the same audit/alerting bucket risks diluting monitoring built around the POCSO obligation. Consider a distinct event type (e.g. check_image_age_not_confirmed) so the audit trail for genuine minor-routing stays meaningful.
| return NextResponse.json({ error: "provider_error" }, { status: 502 }); | ||
| } | ||
|
|
||
| const payload = (await openaiResponse.json()) as { results?: OpenAiResult[] }; |
There was a problem hiding this comment.
WARNING: the OpenAI response body is trusted without schema validation — payload.results, r.outcome, etc. are read directly as the declared OpenAiResult type with no runtime check (no Zod), even though the codebase's own lint rule elsewhere insists on validating external payloads before reading fields. If OpenAI changes/renames a field or returns an unexpected shape, detected silently resolves to false for every check (since r.outcome === "detected" just fails to match) and survivors are shown 'no signal found' instead of an error. Since this never gates help it isn't safety-critical, but it can quietly produce a wrong informational result. Worth validating the upstream shape and surfacing a provider_error if it doesn't match, rather than defaulting to a negative result.
Review: PR #11 — AI image provenance checkerReviewed the new client-side EXIF/C2PA reader ( No CRITICAL findings. The design respects the core safety invariants well:
Three WARNINGs posted inline for the author's judgment (none block merge, but worth tracking before
Verdict: PASS |
| const csrf = await (await fetch("/api/csrf", { cache: "no-store" })).json(); | ||
| const body = new FormData(); | ||
| body.append("file", file); | ||
| body.append("ageConfirmed", "true"); |
There was a problem hiding this comment.
WARNING: runDeepCheck always sends body.append("ageConfirmed", "true") regardless of the actual ageConfirmed" state, so the client never actually forwards the real value of the age checkbox. Currently harmless in effect (the route is off by default via ENABLE_PROVENANCE_CHECK, and the field is unauthenticated client-supplied data anyway per ADR 002's own documented caveat), but it means even a well-behaved browser client can't meaningfully signal 'not confirmed' to the server once a file has been selected — this should read body.append("ageConfirmed", String(ageConfirmed))` so the field isn't permanently dead/misleading code once the flag is eventually turned on.
| <section className="container py-14 md:py-20"> | ||
| <ImageChecker locale={locale} deepCheckEnabled={process.env.ENABLE_PROVENANCE_CHECK === "true"} /> | ||
| <p className="muted mx-auto mt-8 max-w-xl text-center text-xs leading-[1.7]"> | ||
| {t(locale, "check.privacy.note")} |
There was a problem hiding this comment.
WARNING: This privacy note ("the file you choose is never sent to Asmita or anyone else") is rendered unconditionally directly under the checker, including the space where the deeper-check subsection appears when ENABLE_PROVENANCE_CHECK=true. Once that flag is ever turned on, this blanket claim becomes false for any survivor who opts into the deeper check (the file is sent to OpenAI). Consider scoping this note to the on-device reader only, or making it conditional on deepCheckEnabled, so the copy can't drift out of sync with the flag.
Safety/security review — PR #11 (AI image provenance checker)Reviewed the cumulative diff (3 commits) that adds the public This PR already incorporates fixes from a prior review round (visible in the commit history): the OpenAI-forwarding route is now gated behind an off-by-default Checked against the core safety invariants:
Two non-blocking findings posted inline (both WARNING, left inline on the PR):
No exploitable issues, safety-invariant breaks, or data-integrity problems found in the code as it will actually run with current configuration. Verdict: PASS |
What this adds
A privacy-preserving AI image checker at
/check-image, plus the strategic docs for the survivor-security direction.Checker
c2pa+exifr): reads C2PA Content Credentials and IPTC/EXIF AI tags fully in-browser. No upload.content_provenance_checksendpoint (C2PA + SynthID watermark) through a new in-memory server route (/api/check-image) that stores nothing. Guarded by CSRF, IP rate limit, adults-only gate, and explicit consent.Docs
docs/product/survivor-security-roadmap.md— the response-centre roadmap.docs/adr/002-consented-encrypted-media.md— the 'consented, encrypted, time-limited media' charter change (proposed, pending legal), with the documented OpenAI third-party exception.Also
Config
OPENAI_API_KEYset locally and in Vercel for the deeper check. If unset, the deeper check returns 503 and the on-device reader still works.Verification
🤖 Generated with Claude Code