Skip to content

fix(pdf): fail closed when the outline / AcroForm walks hit their node cap - #116

Merged
douglasmun merged 1 commit into
mainfrom
fix-walk-node-cap-fail-closed
Aug 17, 2026
Merged

fix(pdf): fail closed when the outline / AcroForm walks hit their node cap#116
douglasmun merged 1 commit into
mainfrom
fix-walk-node-cap-fail-closed

Conversation

@douglasmun

Copy link
Copy Markdown
Owner

External security review (Codex, 2026-08-17) found one validated CDR bypass and two runbook drift issues. All three are fixed here.

High — validated CDR bypass

_strip_pdf_outlines() ended its walk with continue and _strip_acroform_fields() with break on reaching _MAX_WALK_NODES (100,000), then returned normally. cdr_pdf() shipped the file to the sanitised bucket carrying whatever lived past the cap.

Reproduced at production cap size, not just monkeypatched:

input 11.6 MiB, under _MAX_FILE_BYTES: True
before: sanitised output returned, live /A JavaScript present
after:  REJECTED: PDF outline tree exceeds the 100000-node walk cap;
        cannot prove all outline actions were stripped

Same for a 100,002-node AcroForm /Kids chain. Both are far under the 100 MB _MAX_FILE_BYTES, so no size guard stands in the way. Both now raise CdrReject, matching the fail-closed bound already in _walk_pdf_nodes (pitfall #59).

Why the #60 audit missed it

Pitfall #60 claimed every cap's failure direction had been audited. Its table is a hand-written enumeration of ten constants, and _MAX_WALK_NODES — shared by two functions, named nothing like _PDF_WALK_MAX_NODES — was never on it. check_fail_closed.py could not catch it either: that guard mutates rejections that exist and asks whether a test notices, so it is structurally blind to a cap that never raises at all.

Recorded as pitfall #61, with the cap list re-derived from the source rather than from recall (grep -n '_MAX_[A-Z_]*\|_BUDGET' src/lambda_function.py → 10 constants, 9 previously audited + this one). The #60 table now carries the missed row explicitly rather than reading as complete.

Low — deployment runbook drift

  • The Terraform EventBridge rule is ${resource_prefix}-s3-object-created (verified at terraform/main.tf:363), not the literal cdr-s3-object-created. Operators using the recommended non-default prefix were inspecting a rule that does not exist. Fixed at both sites.
  • MaxMemoryUsed is not an AWS/Lambda CloudWatch metric — the query returns an empty Datapoints list, which reads as "no memory pressure" but means "no such metric". Replaced with the Logs Insights REPORT-line query docs/benchmark.py already uses, plus a pointer to the script. The tuning table made the same claim and is corrected too.

Tests

4 new regression tests: two pin the exception, two pin the outcome (assert b"CAPTAIL" not in clean) so replacing CdrReject with any other silent-completion path still fails. The existing deep-chain tests covered 5,000 nodes but never the cap's failure direction.

check_fail_closed.py now reports 11 pinned rejections, up from 9 — both new ones are load-bearing.

Verification

  • 454 tests pass (was 450)
  • All 7 CI guards pass, including the full check_fail_closed.py mutation run
  • Bypass reproduced pre-fix and confirmed closed post-fix at real cap size

Not done

The review also suggested a CI guard for $PREFIX-parameterized runbook commands. Not implemented — it needs a new script with a real false-positive surface (the SAM path legitimately looks up an auto-generated physical name), and it is scope beyond the two drift fixes.

🤖 Generated with Claude Code

…e cap

_strip_pdf_outlines() ended its walk with `continue` and
_strip_acroform_fields() with `break` on reaching _MAX_WALK_NODES, then
returned normally — cdr_pdf shipped the file as sanitised carrying live
actions past node 100,000. Reproduced at production cap size: an 11.6 MiB
outline chain (well under the 100 MB _MAX_FILE_BYTES) returned sanitised
with live /A JavaScript; same for a /Kids chain. Both now raise CdrReject,
matching _walk_pdf_nodes (pitfall #59).

Also: the #60 cap audit enumerated caps from memory and never listed
_MAX_WALK_NODES; check_fail_closed.py is structurally blind to a cap that
never raises. Recorded as pitfall #61 with the source-enumerated cap list.

Runbook drift: the Terraform EventBridge rule is ${resource_prefix}-s3-
object-created, not the literal cdr-s3-object-created; and MaxMemoryUsed is
not an AWS/Lambda metric — replaced with the Logs Insights REPORT query
docs/benchmark.py already uses.

454 tests pass (4 new); all 7 CI guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@douglasmun
douglasmun merged commit 4dc275a into main Aug 17, 2026
4 checks passed
@douglasmun
douglasmun deleted the fix-walk-node-cap-fail-closed branch August 17, 2026 07:31
douglasmun added a commit that referenced this pull request Aug 17, 2026
…ame (#117)

Lambda, SNS topic, DLQ, IAM role/policy, EventBridge rule and alarm names all
derive from ResourcePrefix, and the runbook tells operators NOT to deploy at
the `cdr` default — a live askkaifbot service already owns those names in
ap-southeast-1. A command hardcoding `cdr-<suffix>` therefore sends the reader
at a resource they do not have, or at somebody else's production, during an
incident. That shipped twice for the EventBridge rule (fixed in #116).

scripts/check_runbook_prefix.py derives the prefixed-name suffixes from
src/template.yaml and terraform/main.tf at runtime (13 of them) rather than
hand-listing them — pitfall #61's lesson applied — and flags a `cdr-<suffix>`
only where it is passed to a command. Prose, parameter tables and S3 tag names
are deliberately untouched: the runbook must be able to advise against the
`cdr` default, and a guard that flags its own advice gets silenced, not fixed.

Section 8 legitimately inspects the FOREIGN cdr-lambda, where $PREFIX would be
wrong; that declares itself with a reasoned `<!-- prefix-literal-ok: … -->`
marker. An empty reason fails, and so does a marker inside a fenced block — an
HTML comment there renders literally and splits a line-continued command.

Five negative controls run, two of which caught real guard bugs: the first
regex missed `--dimensions Name=FunctionName,Value=cdr-lambda` (the form the
runbook's own CloudWatch commands use), and the harness initially counted a
help-text line, making a failing control look like it fired. Recorded as
pitfall #62.

454 tests pass; all 8 CI guards pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant