You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Handoff for the six open PRs that replace #1227. Nothing is merged and nothing is released. Everything below is filed, verified, and waiting on people.
What happened
On 2026-08-11, #1225 and #1227 were merged to main in a single unattended session. The code was sound. The scope and the process were not:
Stop configuring the shared DOMPurify, and give each editor its own allowlist #1227 carried six separable concerns in one branch — a dependency floor bump with security semantics, a new consumer CSP requirement, an allowlist widening, the instance isolation itself, per-editor config, and a build-tooling one-liner. 15 files, +603/−19, about 5× the repo's median PR (3 files, ~99 lines).
Nobody could enumerate what was being merged. The Trusted Types policy and the dompurify bump were noticed hours later, while rewriting the PR description.
No human review, and no manual verification.
The mechanical cost is specific to this repo: there is no CHANGELOG. Release bodies are auto-generated lists of PR titles, and in ~60 releases none has ever flagged a breaking change or a consumer action. Shipping 0.9.30 as-is would have shown consumers only "Stop configuring the shared DOMPurify…" — the CSP requirement and the dependency floor move invisible. Over-scoping the PR silently deletes the release note. Splitting it is what puts it back.
What was done
main is at 8c64aa45, which reverts #1227 whole and is CI-green. #1225 is untouched — one concern, four files, within house norms. This was free: nothing was published (npm and RubyGems are still on 0.9.29), and every consumer pins an exact version, so no consumer saw anything.
Re-filed as five draft PRs plus the rebased #1226. Merge in order:
Only 5 genuinely depends on 4; they are stacked in merge order so each PR's diff is exactly its own concern.
The decomposition changed no production code — but the review round since then did, deliberately. As originally pushed, the stack's src/, package.json and yarn.lock were byte-identical to the reverted merge c1db29fd (the only differences being new docs and eight relocated unit tests). That is no longer true: fixing the data:-scoping security bug in #1229 changed src/config/dom_purify.js by ~64 lines, and #1232 changed a ternary to a conditional. Those are real, intended divergences from what #1227 shipped — see the review-round summary below. If you are auditing "what did the decomposition change vs the revert", diff against the first pushed SHA of each branch, not the current one.
integration/sanitizer-campaign (4c7d4126) is the whole stack as one unit; it is the same commit as #1226's head, so #1226's CI run is its CI run.
What the QA found
The two skipped steps — a real browser and a real consumer — each found something.
1. #1232's headline claim was false. Under require-trusted-types-for 'script' in Chromium, the editor fails to initialise whether or not lexxy is allowlisted, at html_helper.js:20 — Lexxy's own DOMParser.parseFromString, which no DOMPurify policy covers. There are ~10 such unwrapped sinks. This is long-standing and untouched by the series; what was wrong was the claim. Worse, #1227had the caveat in a comment and the rewrite deleted it. Title, body, source comment and docs all corrected. Making Lexxy work under TT is separate work and wants its own issue.
2. #1228 does not work for bc3 or fizzy. yarn 1.22 does not run a git dependency's prepare; npm does. Both consumers use yarn 1.22, so github:basecamp/lexxy#<sha> installs with no dist/. My suggestion is to publish an alpha via the existing yarn release:alpha instead, which every package manager can install. Details and options on the PR.
3. #1231 fixes a live bug in bc3.app/javascript/rich_text/sanitize_attachment_content.js sanitizes with the DOMPurify singleton and a per-call config restricted to ["bc-mention","span","img"], on the chat pending-line and Trix paste paths. Under 0.9.29 that config is silently ignored once an editor connects — <a href> and <p> survive where bc3 intends them stripped. Demonstrated with bc3's real config; table on the PR.
4. #1226's system test proved nothing, and its hook may be dead code. Reverting that PR's entire src/ leaves its system test green. preserveSerializedContentHook can only fire on a nested attachment, which no production path currently produces. Two ways out — show the path is real, or drop the hook and keep the safeForXml: true opt-in — and the second also closes the open dompurify exact-pin thread. Needs a human call; detail on #1226.
5. Pre-existing, filed for information, not blocking: an attachment url with javascript: is dropped from the saved value but still reaches img[src] in the editor DOM. Not exploitable (browsers don't execute it there) and strictly better than before #1229.
Apply mXSS-safe SAFE_FOR_XML on attachment content re-inflation #1226 / integration/sanitizer-campaign (4c7d4126): green, 12/12. It first failed Browser tests (chromium) on leak.test.js; that is a pre-existing flake, not this branch — 4 runs on main at 8c64aa45 failed twice, once reporting a delta of -28 nodes, which is not a leak by any reading. Same 2-of-3 pattern locally on both branches. It passed on re-run with no code change. Worth deflaking separately: it will keep costing re-runs, and a test that reports negative leaks cannot detect a real one.
Browser, Chromium, integration branch: two editors with differing configs each keep their own allowlist; the one denying content strips it and the one allowing it keeps it (the named must-pass regression); attachment data: urls survive and javascript: is dropped from the value; the CSP-refused path is caught and degrades as designed.
bc3, against a build of the integration branch: 73 system tests and 132 rich-text/mention/attachment tests, 0 failures. Local only — the scratch worktree is restored to 0.9.29 and nothing was committed or pushed to bc3.
What still needs a human
External review of all six PRs. They are drafts with Copilot requested. This is the step whose absence caused all of the above.
Release, and write the notes by hand this once. Titles carry the two consumer actions, but a release with a CSP requirement in it deserves more than a generated list.
Then bump bc3 — including moving the dompurify pin in bothdependencies and resolutions, or bc3 stays on 3.4.0 regardless of what lexxy asks for. yarn warns; it does not fail.
Known accepted cost
lexxy is public (1,208 stars, 114 forks) and #1225/#1227 already publicly describe sanitizer bypasses that are still unfixed in the released 0.9.29. The revert does not close that window — only a release does. Deliberately not releasing yet means accepting it for the length of the soak. That is a real trade and someone should own it consciously rather than inherit it.
Cleanup
integration/qa-build (4b0c9b9d) is integration/sanitizer-campaign plus a force-added dist/, built solely so yarn 1 could install it for the bc3 QA. Not for merge. Delete it once the campaign lands.
Handoff for the six open PRs that replace #1227. Nothing is merged and nothing is released. Everything below is filed, verified, and waiting on people.
What happened
On 2026-08-11, #1225 and #1227 were merged to
mainin a single unattended session. The code was sound. The scope and the process were not:dompurifybump were noticed hours later, while rewriting the PR description.The mechanical cost is specific to this repo: there is no CHANGELOG. Release bodies are auto-generated lists of PR titles, and in ~60 releases none has ever flagged a breaking change or a consumer action. Shipping 0.9.30 as-is would have shown consumers only "Stop configuring the shared DOMPurify…" — the CSP requirement and the dependency floor move invisible. Over-scoping the PR silently deletes the release note. Splitting it is what puts it back.
What was done
mainis at8c64aa45, which reverts #1227 whole and is CI-green. #1225 is untouched — one concern, four files, within house norms. This was free: nothing was published (npm and RubyGems are still on 0.9.29), and every consumer pins an exact version, so no consumer saw anything.Re-filed as five draft PRs plus the rebased #1226. Merge in order:
preparebuildsdiston installdompurify ^3.3.0 → ^3.4.13+urlvalidationjavascript:in an attachment url now droppedalt/width/heightin attachment contentlexxyTrusted Types policy nameSAFE_FOR_XMLon re-inflationOnly 5 genuinely depends on 4; they are stacked in merge order so each PR's diff is exactly its own concern.
The decomposition changed no production code — but the review round since then did, deliberately. As originally pushed, the stack's
src/,package.jsonandyarn.lockwere byte-identical to the reverted mergec1db29fd(the only differences being new docs and eight relocated unit tests). That is no longer true: fixing thedata:-scoping security bug in #1229 changedsrc/config/dom_purify.jsby ~64 lines, and #1232 changed a ternary to a conditional. Those are real, intended divergences from what #1227 shipped — see the review-round summary below. If you are auditing "what did the decomposition change vs the revert", diff against the first pushed SHA of each branch, not the current one.integration/sanitizer-campaign(4c7d4126) is the whole stack as one unit; it is the same commit as #1226's head, so #1226's CI run is its CI run.What the QA found
The two skipped steps — a real browser and a real consumer — each found something.
1. #1232's headline claim was false. Under
require-trusted-types-for 'script'in Chromium, the editor fails to initialise whether or notlexxyis allowlisted, athtml_helper.js:20— Lexxy's ownDOMParser.parseFromString, which no DOMPurify policy covers. There are ~10 such unwrapped sinks. This is long-standing and untouched by the series; what was wrong was the claim. Worse, #1227 had the caveat in a comment and the rewrite deleted it. Title, body, source comment and docs all corrected. Making Lexxy work under TT is separate work and wants its own issue.2. #1228 does not work for bc3 or fizzy. yarn 1.22 does not run a git dependency's
prepare; npm does. Both consumers use yarn 1.22, sogithub:basecamp/lexxy#<sha>installs with nodist/. My suggestion is to publish an alpha via the existingyarn release:alphainstead, which every package manager can install. Details and options on the PR.3. #1231 fixes a live bug in bc3.
app/javascript/rich_text/sanitize_attachment_content.jssanitizes with the DOMPurify singleton and a per-call config restricted to["bc-mention","span","img"], on the chat pending-line and Trix paste paths. Under 0.9.29 that config is silently ignored once an editor connects —<a href>and<p>survive where bc3 intends them stripped. Demonstrated with bc3's real config; table on the PR.4. #1226's system test proved nothing, and its hook may be dead code. Reverting that PR's entire
src/leaves its system test green.preserveSerializedContentHookcan only fire on a nested attachment, which no production path currently produces. Two ways out — show the path is real, or drop the hook and keep thesafeForXml: trueopt-in — and the second also closes the opendompurifyexact-pin thread. Needs a human call; detail on #1226.5. Pre-existing, filed for information, not blocking: an attachment
urlwithjavascript:is dropped from the saved value but still reachesimg[src]in the editor DOM. Not exploitable (browsers don't execute it there) and strictly better than before #1229.What passed
integration/sanitizer-campaign(4c7d4126): green, 12/12. It first failedBrowser tests (chromium)onleak.test.js; that is a pre-existing flake, not this branch — 4 runs onmainat8c64aa45failed twice, once reporting a delta of-28nodes, which is not a leak by any reading. Same 2-of-3 pattern locally on both branches. It passed on re-run with no code change. Worth deflaking separately: it will keep costing re-runs, and a test that reports negative leaks cannot detect a real one.contentstrips it and the one allowing it keeps it (the named must-pass regression); attachmentdata:urls survive andjavascript:is dropped from the value; the CSP-refused path is caught and degrades as designed.0.9.29and nothing was committed or pushed to bc3.What still needs a human
dompurifypin in bothdependenciesandresolutions, or bc3 stays on 3.4.0 regardless of what lexxy asks for. yarn warns; it does not fail.Known accepted cost
lexxy is public (1,208 stars, 114 forks) and #1225/#1227 already publicly describe sanitizer bypasses that are still unfixed in the released 0.9.29. The revert does not close that window — only a release does. Deliberately not releasing yet means accepting it for the length of the soak. That is a real trade and someone should own it consciously rather than inherit it.
Cleanup
integration/qa-build(4b0c9b9d) isintegration/sanitizer-campaignplus a force-addeddist/, built solely so yarn 1 could install it for the bc3 QA. Not for merge. Delete it once the campaign lands.