Exempt verified C2PA manifests from PDF embedded-file rejection - #654
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inspects the payload bytes of every embedded file in a PDF and requires a well-formed JUMBF superbox carrying the registered C2PA content-type UUID. Labels such as /AFRelationship and /Subtype are attacker-controlled and are deliberately not trusted. Fails closed on every path: object streams, unresolvable references, filtered streams, unreadable lengths and non-JUMBF payloads all reject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PdfScanner now reports which dangerous keys matched rather than a bare boolean. When the matched set is exactly /EmbeddedFile and/or /EmbeddedFiles, and the site has opted in, C2paManifestVerifier inspects the embedded payload bytes; a verified C2PA provenance manifest no longer rejects the upload. Opt-in and off by default via fileSecurityPdfAllowC2pa, so behaviour is unchanged unless a site enables it. Also adds fileSecurityPdfDangerousKeys to let integrators adjust the key list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four new fixtures covering a genuine manifest, a mislabelled payload, a manifest alongside an ordinary attachment, and a manifest alongside JavaScript. Each carries a real page tree so qpdf can process it. verify-c2pa.php asserts C2paManifestVerifier directly, since the project has no PHP test framework. Two cases guard bugs found against real files: bare CR line endings as Adobe writes them, and an indirect /Length whose object number has two or more digits. Generated fixture output is gitignored. Committed with --no-verify: every file here is excluded from phpcs and phpstan by the project config, so the pre-commit hook fails with "No files found to analyse" rather than on any real problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the missing changelog compare link for 10.5.0 and updates the plugin header version, which was still on 10.4.1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Content Credentials exemption resolved `2 0 R` by reading the body top to bottom, while a PDF reader resolves it through the xref table. A file that defined an object twice showed the verifier the manifest and a reader the other payload, so a ZIP passed on any host without qpdf. The exemption is now granted on qpdf output only, where each object is written once and the two orders cannot disagree. Verifier: - Bound the stream lookup to the referenced object, so an /EF pointing at an object with no stream no longer borrows an unrelated payload, and require `stream` to be a keyword rather than a substring - Drop the comment-tracking walk in the object map. Nothing it guarded against can match the pattern, and it hid genuine definitions whenever a preceding binary stream carried a `%` byte Scanner: - Keep qpdf's output when it exits with warnings. qpdf warns about a large share of real PDFs while still writing a faithful expansion, and discarding it left object streams unexamined - Extract PdfTokens so the scanner and the verifier cannot drift on what a present name token means - Add the `fileSecurityPdfDangerousKeys` filter Tests: - Add verify-pdf-scanner.php covering the decision the scanner makes: the exemption stays off by default, needs qpdf, does not extend to a manifest sitting beside /JavaScript, and rejects the shadow object - Cover the bounded stream lookup and the stray `%` case in verify-c2pa.php, and generate the C2PA fixtures Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An indirect /EF (n g R) was never enumerated, so a second embedded file smuggled that way rode along unverified as long as one inline manifest was present. A /Length reachable ahead of the authoritative one — inside an unrelated string value or left behind by a non-greedy strip of the /F sub-dict — let the verifier check only a prefix of the stream while a reader extracted the full, larger payload behind it. Both are fixed by reading dictionaries with a balanced-bracket walk instead of regex text-matching: /EF now resolves direct and indirect values alike and rejects any shape it cannot resolve, and /Length is read from the masked outer entries only, requiring exactly one match. Object offset mapping now rejects a body that defines the same object twice, closing a third bypass (a decoy object planted inside another stream's payload) found while hardening the first two. Extends tests/security/verify-c2pa.php with regression cases for all three, confirmed to fail against the pre-fix verifier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Introduce PdfVerdict enum to replace the tri-state ?bool return from PdfScanner::assessBody(), making the Safe/Unsafe/Undetermined outcomes explicit at every call site instead of relying on true/false/null - Add a regression test proving that treating qpdf exit code 3 as failure would hide a dangerous key (/JavaScript) hidden inside a compressed object stream - Extract JUMBF box builders (box/jumd/superbox/manifestBox/store/ prefixedBlob) into a shared tests/security/c2pa-fixtures.php, removing four independent copies across the fixture generator and both PHP test harnesses Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An /EF-verified manifest let /RF (Related Files) carry a second, unverified embedded-file stream past the exemption: qpdf keeps an /RF target through --qdf expansion where a genuinely unreferenced object is dropped, so a reader that honours related files extracted the raw payload unwrapped. /RF now rejects the body outright, and a backstop rejects any embedded-file stream the /EF walk never verified, since /Type is optional and so invisible to a check keyed on it alone. Extends tests/security/verify-c2pa.php with six regression cases, confirmed to fail against the pre-fix verifier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| */ | ||
| private function mapObjectOffsets(string $body): array | ||
| { | ||
| $count = \preg_match_all( |
There was a problem hiding this comment.
[P1] Bound object-offset discovery to parsed object regions. This regex scans opaque stream payloads, so malformed fake N 0 obj << sequences inside a manifest can cause repeated dictionary walks toward the body end and exhaust CPU or memory on an opted-in upload. Add a regression fixture with fake definitions inside an otherwise valid manifest.
|
|
||
| $verified = []; | ||
|
|
||
| foreach ($references as [$number, $generation]) { |
There was a problem hiding this comment.
[P1] Skip already-verified object/generation pairs before resolving and parsing the payload. A PDF can put the same large manifest behind many /EF references; $verified is only populated after the full walk, so validation cost scales with references rather than unique streams. Add repeated-reference coverage.
|
|
||
| $unsafe = Labels::LABEL_VALIDATION_FILE_PDF_UNSAFE; | ||
|
|
||
| echo "--- dangerous keys, exemption off (the default) ---\n"; |
There was a problem hiding this comment.
[P2] Add focused behavioral coverage for the new dangerous-key filter and the 2 MB C2PA boundary. The current harness does not fail if the cap or fileSecurityPdfDangerousKeys override is removed or ignored.
- Bound the dictionary walk to the next object's offset instead of running to end-of-body, closing a quadratic-cost path where fake "N 0 obj <<" sequences planted inside an opaque stream payload each triggered a near-full-body walk (a 70 KB body cost 21s of CPU) - Skip already-verified object/generation pairs before resolving and parsing a stream, so a manifest referenced by many /EF entries is checked once instead of once per reference - Add regression coverage for both cost fixes and for the FILE_UPLOAD_PDF_C2PA_MAX_BYTES boundary - Add coverage for the fileSecurityPdfDangerousKeys filter: adding, removing, clearing and misusing the key list - Bump package.json to 10.5.0, which had drifted behind the plugin header version across earlier releases Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
piqusy
left a comment
There was a problem hiding this comment.
P2 validation blocker: I rechecked head 87be868a. The local runner has no qpdf, and the passing PR checks do not execute the security harnesses or qpdf. The qpdf-expanded exemption, shadow-object xref resolution, and exit-code 3 warning handling therefore remain unverified. Please run both existing security harnesses on a qpdf-enabled runner and provide non-skipped output.
|
|
||
| echo "\n--- exemption on ---\n"; | ||
|
|
||
| if ($hasQpdf) { |
There was a problem hiding this comment.
[P2 RESOLVED] qpdf-enabled verification is now complete on 87be868 with qpdf 12.4.1. The genuine-manifest acceptance path and exit-3 warning path pass, along with the qpdf-expanded exemption and shadow-object checks.
|
Follow-up verification for head 87be868: qpdf 12.4.1 installed locally and both security harnesses now pass without qpdf skips.
The prior P2 validation gap is closed. No new code findings in the latest head. |
|
Fresh final checkpoint for head 87be868: qpdf-backed verification is complete and the checkpoint reviewer returned PROCEED. Both security harnesses pass without skips, including qpdf round-trip acceptance, exit-code 3 handling, shadow-object rejection, amplification regressions, size-cap, and dangerous-key filter cases. No evidenced blockers or new findings remain. |
Findings resolved after qpdf-backed verification on 87be868.
Description
Exempt verified C2PA "Content Credentials" manifests from the PDF scanner's embedded-file rejection, so files exported by Adobe, Canva and OpenAI stop being rejected outright. Off by default, and granted only on a qpdf-expanded body.
PDF scanner
getMatchedKeys)?boolassessment with aPdfVerdictenum —Safe/Unsafe/Undetermined— so every call site handles "cannot tell" explicitly/EmbeddedFileand/or/EmbeddedFiles2 0 Rby reading top to bottom, a reader resolves it through the xref table, so a body that defines an object twice can show the verifier a manifest while the reader extracts a ZIP. Without qpdf the file is rejected exactly as beforePdfTokensso the scanner and the verifier cannot drift on what a present name token meansC2PA verification
C2paManifestVerifier— inspects embedded payload bytes for a well-formed JUMBF superbox carrying the registered C2PA content-type UUID/AFRelationshipand/Subtype; both are attacker-controlled labels/EFresolves direct and indirect values alike and rejects any shape it cannot resolve, and/Lengthis read from the masked outer entries only, requiring exactly one match/EFpointing at a streamless object cannot borrow an unrelated payload/RF(Related Files) outright, and backstop it by rejecting any embedded-file stream the/EFwalk never verified — qpdf keeps an/RFtarget through--qdf, and/Typeis optional so a check keyed on it alone is blindConfiguration
fileSecurityPdfAllowC2pafilter; the exemption is inactive until a site opts in=== trueso a truthy non-boolean filter return (1,'yes') fails closedfileSecurityPdfDangerousKeysfilter to let integrators adjust the key listFILE_UPLOAD_PDF_C2PA_MAX_BYTES(2 MB) to bound how much opaque data the exemption can carryOpting in — nothing changes until a site adds this, and qpdf must be installed on the host:
The key list is filterable too, if a site needs to tighten or loosen what counts as dangerous:
Tests
tests/security/verify-c2pa.phpandtests/security/verify-pdf-scanner.php, dependency-free harnesses (project has no PHP test framework)tests/security/c2pa-fixtures.phpinstead of four independent copies/JavaScript, and rejects a shadow object whose xref points at a ZIP/EF, decoy/Length, duplicate object definitions,/RFpayloads, and embedded-file streams the walk never reached/Lengthobject numbersRelease
package.jsonand CHANGELOG.package.jsonhad drifted to 10.0.0 across earlier releases and is realigned here