Skip to content

fix(security): bound text length before stripping tags - #75

Merged
dknauss merged 1 commit into
mainfrom
fix/security-hardening
Aug 4, 2026
Merged

fix(security): bound text length before stripping tags#75
dknauss merged 1 commit into
mainfrom
fix/security-hardening

Conversation

@dknauss

@dknauss dknauss commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Fixes a CPU-exhaustion vulnerability found by an adversarial security review of the REST surface. Confirmed by measurement, not inference.

The bug

Every CSL string is run through a looped wp_strip_all_tags(). Core's implementation first applies a lazy script/style-stripping regex that exhausts PCRE's JIT stack past roughly 600 KB and degrades to catastrophic backtracking. Measured against core's real implementation:

Payload Cost
400 KB 0.00s
600 KB 2.41s
800 KB 7.73s
950 KB 12.86s

A cliff, not a slope. BIBLIOGRAPHY_BUILDER_MAX_FORMAT_BYTES bounds the whole request body, but nothing bounded an individual value — so one field could carry the entire megabyte into that regex.

Two halves, and the second is worse

Write pathPOST /format, reachable by anyone with edit_posts (Contributor). Costs the attacker one request per hit.

Read pathGET /posts/{id}/bibliographies/{index}?format=text, plus the formatted-text sanitizer. This one is materially worse:

  • bibliography_builder_can_read_post() returns true unconditionally for any publish post, so no authentication is required
  • Block attributes are not schema-validated on save, so the payload can be written straight into post_content, never passing through the formatter or its guard
  • Once stored, every subsequent read pays the cost again, free to the caller

The fix

Write path rejects strings over 64 KB — checked once over the decoded item, upstream of every strip. Deliberately not per call site: stripping is reached from flat string fields, author name parts, date literal/raw values, and both branches of the string-or-array handler. A limit on one leaves the same cost reachable under a different key. (The first version of this patch did exactly that and was rejected in review.)

Read path truncates to the same bound rather than rejecting, because refusing to render a bibliography that already exists would break the post rather than protect it.

The bound is applied per citation, never to the joined response. Capping the whole body would add nothing — the per-citation bound already keeps aggregate cost linear — and would silently drop the tail of an ordinary bibliography: 50 annotated entries exceed 64 KB on their own, well inside the supported 200-citation limit. (The third version of this patch did that too, and was also rejected.)

The test harness was hiding it

bootstrap.php stubbed wp_strip_all_tags as bare strip_tags(), omitting the regex that is the entire cost. No existing test could have observed this. The stub now mirrors core.

That also corrected an existing assertion: it expected <script>alert(1)</script> to strip to alert(1), but WordPress removes the element and its contents, giving Safe. That test was documenting a fiction the stub invented.

Verification

Every fix measured in both directions:

RED GREEN
Write path, all 5 strip sites (7 cases) 74s wall, 6 of 7 failing 0.067s
Read path, both stripping paths 24.4s wall 0.003s
Response-truncation regression 41 of 50 entries survive 50 of 50

187 tests / 428 assertions. PHPCS, Psalm, i18n, and verify-metrics clean.

Review history

Rejected three times before approval, each time correctly:

  1. Capped one of five call sites — the other four still cost ~12.3s each
  2. Fixed the write path while the unauthenticated read path stayed live
  3. Fixed the read path but reused the single-field cap as a response cap, silently truncating legitimate annotated bibliographies

Every rejected version passed its own tests.

Risk

Contained. No change to block output, save format, or stored data. The read path's only behavioural change is truncating a single stored string past 64 KB — far beyond any real citation.

Not included

The post-type visibility check, the href scheme allowlist, and U+2028/U+2029 escaping are queued for a separate PR. None is a confirmed vulnerability, and this diff has been wrong enough times to be worth landing on its own.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 81.05%. Comparing base (ecadee1) to head (bbc8b29).

Files with missing lines Patch % Lines
bibliography-builder.php 96.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #75      +/-   ##
==========================================
+ Coverage   80.94%   81.05%   +0.10%     
==========================================
  Files          43       43              
  Lines        3128     3151      +23     
  Branches      523      523              
==========================================
+ Hits         2532     2554      +22     
- Misses        190      191       +1     
  Partials      406      406              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Every CSL string is run through wp_strip_all_tags, and core's script/style
regex exhausts PCRE's JIT stack past roughly 600 KB and degrades to
catastrophic backtracking. Measured against core's real implementation:
400 KB is free, 600 KB costs 2.4s, 950 KB costs 12.9s. The request cap
bounds the whole body, so a single value could carry the entire megabyte
into that regex.

Write path: reject strings over 64 KB, checked once over the decoded item
before any stripping. The check is deliberately not per call site --
stripping is reached from flat string fields, author name parts, date
literal and raw values, and both branches of the string-or-array handler,
so a limit on one leaves the cost reachable under a different key.

Read path, which was the worse half: the plain-text route and the
formatted-text sanitizer stripped stored citation text unbounded. That
payload lives in post_content, the route needs no authentication for a
published post, and block attributes are not schema-validated on save, so
it could be written directly rather than through the formatter -- making
it free and repeatable rather than costing a request per hit. Stored text
is truncated to the same bound instead of rejected, because refusing to
render a bibliography that already exists would break the post.

The bound is applied per citation, never to the joined response: 50
annotated entries exceed 64 KB on their own, so capping the whole body
would silently drop the tail of an ordinary bibliography while adding
nothing, the per-citation bound already keeping aggregate cost linear.

The test harness stubbed wp_strip_all_tags as bare strip_tags, omitting
the regex that is the entire cost, which is why no existing test could
observe this. The stub now mirrors core. That also corrected an
assertion which expected script contents to survive stripping as text;
WordPress removes the element and its contents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dknauss
dknauss force-pushed the fix/security-hardening branch from 91f4f3f to bbc8b29 Compare August 4, 2026 00:24
@dknauss
dknauss merged commit 6e10219 into main Aug 4, 2026
16 checks passed
@dknauss
dknauss deleted the fix/security-hardening branch August 4, 2026 00:32
dknauss added a commit that referenced this pull request Aug 5, 2026
Bump version to 1.5.0 across the plugin header, block.json, and the
package manifests, and move the readme stable tag. Promote the
CHANGELOG [Unreleased] section to [1.5.0].

Minor rather than patch: the Block Accessibility Checks integration now
requires BAC 4.0, so on BAC 3.x the bibliography checks silently stop
appearing. That is a behavior change for existing users, not a fix.

Port the security entries into readme.txt, which had none. CHANGELOG.md
documented six, but readme.txt is what installed users and WordPress.org
readers actually see.

Separate them by severity rather than listing six equal Security: bullets.
Only the two from #75 were exploitable, and the read-path half shipped in
1.4.2 -- unauthenticated, free, repeatable, ~12s of CPU per request. The
three from #76 were never exploitable; that commit says so in its first
line. Flattening all six would have made three hardening changes read as
live vulnerabilities while burying the one that justifies the upgrade,
which is the opposite of what a changelog is for. The U+2028/U+2029
escaping loses the Security label entirely: ld+json is never executed,
and the commit that made the change calls it not a security issue.

Upgrade Notice retargeted at the denial-of-service fix alone and kept
under the 300-character wp.org guidance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dknauss added a commit that referenced this pull request Aug 5, 2026
Bump version to 1.5.0 across the plugin header, block.json, and the
package manifests, and move the readme stable tag. Promote the
CHANGELOG [Unreleased] section to [1.5.0].

Minor rather than patch: the Block Accessibility Checks integration now
requires BAC 4.0, so on BAC 3.x the bibliography checks silently stop
appearing. That is a behavior change for existing users, not a fix.

Port the security entries into readme.txt, which had none. CHANGELOG.md
documented six, but readme.txt is what installed users and WordPress.org
readers actually see.

Separate them by severity rather than listing six equal Security: bullets.
Only the two from #75 were exploitable, and the read-path half shipped in
1.4.2 -- unauthenticated, free, repeatable, ~12s of CPU per request. The
three from #76 were never exploitable; that commit says so in its first
line. Flattening all six would have made three hardening changes read as
live vulnerabilities while burying the one that justifies the upgrade,
which is the opposite of what a changelog is for. The U+2028/U+2029
escaping loses the Security label entirely: ld+json is never executed,
and the commit that made the change calls it not a security issue.

Upgrade Notice retargeted at the denial-of-service fix alone and kept
under the 300-character wp.org guidance.

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