fix(security): bound text length before stripping tags - #75
Merged
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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
force-pushed
the
fix/security-hardening
branch
from
August 4, 2026 00:24
91f4f3f to
bbc8b29
Compare
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>
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.
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:A cliff, not a slope.
BIBLIOGRAPHY_BUILDER_MAX_FORMAT_BYTESbounds 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 path —
POST /format, reachable by anyone withedit_posts(Contributor). Costs the attacker one request per hit.Read path —
GET /posts/{id}/bibliographies/{index}?format=text, plus the formatted-text sanitizer. This one is materially worse:bibliography_builder_can_read_post()returnstrueunconditionally for anypublishpost, so no authentication is requiredpost_content, never passing through the formatter or its guardThe 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.phpstubbedwp_strip_all_tagsas barestrip_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 toalert(1), but WordPress removes the element and its contents, givingSafe. That test was documenting a fiction the stub invented.Verification
Every fix measured in both directions:
187 tests / 428 assertions. PHPCS, Psalm, i18n, and
verify-metricsclean.Review history
Rejected three times before approval, each time correctly:
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
hrefscheme 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