Skip to content

Exempt verified C2PA manifests from PDF embedded-file rejection - #654

Merged
iobrado merged 11 commits into
release/10.5.0from
feature/c2pa-manifest-exemption
Sep 22, 2026
Merged

iobrado merged 11 commits into
release/10.5.0from
feature/c2pa-manifest-exemption

Conversation

@iobrado

@iobrado iobrado commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Report which dangerous keys matched instead of a bare boolean (getMatchedKeys)
  • Replace the tri-state ?bool assessment with a PdfVerdict enum — Safe / Unsafe / Undetermined — so every call site handles "cannot tell" explicitly
  • Exempt a body only when the matched set is exactly /EmbeddedFile and/or /EmbeddedFiles
  • Keep rejecting anything carrying a manifest alongside another dangerous key
  • Grant the exemption on qpdf output only. A raw body resolves 2 0 R by 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 before
  • Keep qpdf's output when it exits with warnings. qpdf warns about a large share of real PDFs — a reconstructed xref, a repaired page tree — while still writing a faithful expansion, and discarding it left compressed object streams unexamined. This one affects every PDF upload, not just C2PA ones
  • Extract PdfTokens so the scanner and the verifier cannot drift on what a present name token means

C2PA verification

  • Add C2paManifestVerifier — inspects embedded payload bytes for a well-formed JUMBF superbox carrying the registered C2PA content-type UUID
  • Ignore /AFRelationship and /Subtype; both are attacker-controlled labels
  • Read dictionaries with a balanced-bracket walk rather than regex text matching: /EF 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
  • Reject a body that defines the same object twice, and bound the stream lookup to the referenced object so an /EF pointing at a streamless object cannot borrow an unrelated payload
  • Reject /RF (Related Files) outright, and backstop it by rejecting any embedded-file stream the /EF walk never verified — qpdf keeps an /RF target through --qdf, and /Type is optional so a check keyed on it alone is blind
  • Fail closed on object streams, unresolvable refs, filtered streams, unreadable lengths and non-JUMBF payloads
  • Structural check only — makes no claim about signature authenticity

Configuration

  • Add fileSecurityPdfAllowC2pa filter; the exemption is inactive until a site opts in
  • Compare with === true so a truthy non-boolean filter return (1, 'yes') fails closed
  • Add fileSecurityPdfDangerousKeys filter to let integrators adjust the key list
  • Add FILE_UPLOAD_PDF_C2PA_MAX_BYTES (2 MB) to bound how much opaque data the exemption can carry

Opting in — nothing changes until a site adds this, and qpdf must be installed on the host:

\add_filter('es_forms_validation_file_security_pdf_allow_c2pa', '__return_true');

The key list is filterable too, if a site needs to tighten or loosen what counts as dangerous:

\add_filter('es_forms_validation_file_security_pdf_dangerous_keys', static function (array $keys): array {
	return \array_merge($keys, ['/GoToR']);
});

Tests

  • Add tests/security/verify-c2pa.php and tests/security/verify-pdf-scanner.php, dependency-free harnesses (project has no PHP test framework)
  • Share the JUMBF box builders through tests/security/c2pa-fixtures.php instead of four independent copies
  • Fixtures: genuine manifest, mislabelled payload, JUMBF-prefixed ZIP, manifest + ordinary attachment, manifest + JavaScript, and a compressed object stream that makes qpdf exit with warnings
  • Scanner-level coverage for the decision that matters: the exemption stays off by default, needs qpdf, does not extend to a manifest sitting beside /JavaScript, and rejects a shadow object whose xref points at a ZIP
  • Regression cases for every bypass closed here, each confirmed to fail against the pre-fix verifier: indirect /EF, decoy /Length, duplicate object definitions, /RF payloads, and embedded-file streams the walk never reached
  • Cover two bugs found against real files: bare CR line endings as Adobe writes them, and multi-digit indirect /Length object numbers
  • Gitignore generated fixture output

Release

  • Bump to 10.5.0 — plugin header, package.json and CHANGELOG. package.json had drifted to 10.0.0 across earlier releases and is realigned here

iobrado and others added 6 commits September 16, 2026 15:44
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>
iobrado and others added 4 commits September 18, 2026 09:19
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>
@iobrado
iobrado changed the base branch from main to release/10.5.0 September 21, 2026 08:30
@iobrado iobrado changed the title Feature/c2pa manifest exemption Exempt verified C2PA manifests from PDF embedded-file rejection Sep 21, 2026
@iobrado
iobrado marked this pull request as ready for review September 21, 2026 08:54
@iobrado
iobrado requested a review from a team September 21, 2026 08:54
piqusy
piqusy previously requested changes Sep 21, 2026
*/
private function mapObjectOffsets(string $body): array
{
$count = \preg_match_all(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
@iobrado
iobrado requested a review from piqusy September 21, 2026 11:56

@piqusy piqusy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

@piqusy piqusy Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@piqusy

piqusy commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Follow-up verification for head 87be868: qpdf 12.4.1 installed locally and both security harnesses now pass without qpdf skips.

  • C2PA harness: qpdf --qdf round trip, shadow-object, repeated-reference, fake-definition, and size-cap cases pass.
  • PDF scanner harness: genuine C2PA acceptance, qpdf exit 3 handling, shadow-object rejection, and dangerous-key filter cases pass.

The prior P2 validation gap is closed. No new code findings in the latest head.

@piqusy

piqusy commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

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.

@piqusy
piqusy dismissed their stale review September 21, 2026 20:41

Findings resolved after qpdf-backed verification on 87be868.

@iobrado
iobrado merged commit a2307f6 into release/10.5.0 Sep 22, 2026
2 checks passed
@iobrado
iobrado deleted the feature/c2pa-manifest-exemption branch September 22, 2026 13:43
@piqusy piqusy added this to the 10.5.0 milestone Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants