fix(pdf): fail closed when the outline / AcroForm walks hit their node cap - #116
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withcontinueand_strip_acroform_fields()withbreakon 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:
Same for a 100,002-node AcroForm
/Kidschain. Both are far under the 100 MB_MAX_FILE_BYTES, so no size guard stands in the way. Both nowraise 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.pycould 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
${resource_prefix}-s3-object-created(verified atterraform/main.tf:363), not the literalcdr-s3-object-created. Operators using the recommended non-default prefix were inspecting a rule that does not exist. Fixed at both sites.MaxMemoryUsedis not anAWS/LambdaCloudWatch metric — the query returns an emptyDatapointslist, which reads as "no memory pressure" but means "no such metric". Replaced with the Logs Insights REPORT-line querydocs/benchmark.pyalready 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 replacingCdrRejectwith 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.pynow reports 11 pinned rejections, up from 9 — both new ones are load-bearing.Verification
check_fail_closed.pymutation runNot 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