fix(security): patch two XSS sinks, and stabilise the document types E2E test - #238
Merged
Conversation
Two places built HTML from values an attacker can influence. getRichEditorText() in documentate-admin.js assigned the editor's raw value to innerHTML on a detached div purely to strip tags. A detached node still fetches resources, so markup such as <img src=x onerror=...> stored in a document runs when an editor validates the form - a stored XSS reachable by any contributor who can save content. Parse into an inert document with DOMParser instead. This is the CodeQL js/xss-through-dom alert. renderUserAvatars() in documentate-collaborative-editor.js interpolated user.avatar and user.name straight into an img tag, inside a quoted attribute. Those values come from Yjs awareness state, which every peer in the room publishes for itself through the signaling server - and the default server is the public wss://signaling.yjs.dev, so the room is not private. A peer whose display name is `"><img src=x onerror=...>` runs code in every other editor's browser. Build the element and assign src and alt as properties. CodeQL does not flag this one; it was found while auditing the other innerHTML sites. The remaining innerHTML uses were checked and left alone: escapeHtml() in documentate-revisions.js and documentate-actions.js is the safe idiom (textContent in, innerHTML out), and createToolbarHTML() interpolates only an internal DOM id.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
erseco
force-pushed
the
fix/xss-and-e2e-flake
branch
from
July 28, 2026 09:36
07e04a5 to
09e7cba
Compare
erseco
force-pushed
the
fix/xss-and-e2e-flake
branch
from
July 28, 2026 13:04
09e7cba to
ef8050e
Compare
document-types.spec.js "can create new document type with name" failed intermittently, and always once the site had accumulated enough terms. Root cause: the test creates `Test Type <timestamp>` and never removes it, while the assertion looked for the new row on the unfiltered first page of a list WordPress paginates at 20. Every run leaks one term, so after enough runs new terms land on page 2 and the assertion stops finding them. The local environment had 35 document types, 18 of them left over from earlier runs. That is also why CI, which starts from a fresh site, mostly passed. Reload filtered by name instead, so the assertion no longer depends on where the term falls in the list or on how many exist. Separately, DocumentTypesPage.create() registered waitForResponse after clicking submit. waitForResponse only observes responses that arrive after it is called, so a fast server answer was missed and the call timed out - the other failure mode seen on this spec. Register the listener first, then click, then await it, and wait for the row the AJAX handler appends. Verified against the polluted environment that caused the failures: 5 consecutive runs of the affected test pass with 26 terms present, where it previously failed 5 out of 5, and the full suite is 73 passed with 0 failures. Terms are still leaked; that no longer breaks anything, but cleaning up after the test would be worth doing separately. Adding it here through the row action was tried and dropped, since it introduced more UI surface to a change meant to remove fragility.
erseco
force-pushed
the
fix/xss-and-e2e-flake
branch
from
July 28, 2026 17:07
ef8050e to
3e669a3
Compare
The regression suite added with the XSS fix never ran in CI: lint_and_test installed npm dependencies and went straight to PHPUnit, and the e2e job only runs Playwright. A security regression it exists to catch would have landed unnoticed. Split `npm ci` out of the wp-env step and run `npm run test:unit-js` before the container starts, so a JS failure surfaces in seconds rather than after the boot and the PHP suite. Also corrects the comment on extractPlainText(). It claimed the parsed document "neither runs scripts nor loads resources". The first half is the guarantee that matters and is real - no browsing context means scripting is disabled, so inline handlers do not fire - but the second half overstates the API: a parsed document may still fetch subresources declared by elements such as <img>. The comment now claims only what the spec provides, since the fix rests on handlers not executing, not on resources never being requested.
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.
Two independent fixes, one commit each.
1. Cross-site scripting
The reported one —
documentate-admin.jsgetRichEditorText()assigned the editor's raw value toinnerHTMLon a detacheddiv, purely to strip tags and read the text back:Being detached does not make this inert — the node still belongs to the active document, with scripting enabled, so the
onerrorhandler of<img src=x onerror=...>saved in a document fires the moment an editor triggers form validation. That is a stored XSS reachable by any contributor who can save content, and it is the CodeQLjs/xss-through-domalert.Replaced with
DOMParser, extracted intoextractPlainText():A document from
parseFromStringhas no browsing context, so scripting is disabled: neither scripts nor inline event handlers run. That is precisely the difference frominnerHTMLon a detached node, which still belongs to the active document and did fire theonerrorhandler. The parsed tree is never inserted anywhere; onlybody.textContentis read.(Note the guarantee is scoped deliberately: the spec disables scripting, it does not promise that no subresource is ever fetched. The fix rests on handlers not executing.)
CodeQL flags this line, and that flag is a false positive — dismissed as such on alert #521. The rule fires on any HTML parse of untrusted text without distinguishing the inert case. Two alternatives were tried and rejected:
<p> </p>,<p> </p>and<p title="1 > 0"></p>all came back non-empty, so a visually empty field would pass arequiredcheck. Real parsing is the only thing that decodes entities and understands>inside an attribute value.editor.getContent({ format: 'text' }). Asking TinyMCE for its own text looked cleanest and broke saving:getContent()fires TinyMCE'sSaveContenthandlers, and WordPress's integration writes that result back into the textarea, overwriting the HTMLsave()had just put there. Documents saved empty. Caught by two rich-content E2E specs.Correctness wins over silencing the linter.
A second one, not reported —
documentate-collaborative-editor.jsFound while auditing the other
innerHTMLsites. CodeQL does not flag it, and it is the more serious of the two:usercomes fromawareness.getStates()— Yjs state that every peer in the room publishes about itself via the signaling server. The default signaling server iswss://signaling.yjs.dev, which is public, so the room is not private either. A peer whose display name is"><img src=x onerror=...>breaks out of the quoted attribute and runs code in every other editor's browser.Fixed by building the element and assigning
srcandaltas properties.Checked and left alone
escapeHtml()indocumentate-revisions.jsanddocumentate-actions.js— the safe idiom (textContentin,innerHTMLout).createToolbarHTML()— interpolates only an internal DOM id.Regression test
tests/js/documentate-admin-rich-text.test.jspins the emptiness contract this validation depends on — 19 cases covering exactly what the regex attempt got wrong:<p> </p>,<p> </p>,<p> </p>""— empty<p title="1 > 0"></p>""— empty<!-- nota interna -->""— empty<img src=x onerror=alert(1)>"", and never executed<p>&</p>"&"— decoded<p title="1 > 0">Texto</p>"Texto"The tests were verified to catch the bug: swapping the regex implementation back in makes 6 of them fail.
extractPlainTextis exported under CommonJS so the tests can reach it, and the IIFE argument tolerates a missing jQuery. WordPress serves this file as a plain script wheremoduleis undefined, so neither branch is taken in the browser and runtime behaviour is unchanged.2. The flaky document-types E2E test
document-types.spec.js"can create new document type with name" failed intermittently, and reliably once enough test data existed.Root cause. The test creates
Test Type <timestamp>and never removes it, while the assertion looked for the new row on the unfiltered first page of a list WordPress paginates at 20. Every run leaks one term; after enough runs the new term lands on page 2. The environment where this was investigated had 35 document types, 18 of them left over from earlier runs. That also explains why CI, which starts from a fresh site, mostly passed.Fixed by reloading the list filtered by name, so the assertion no longer depends on where the term falls or how many exist.
A second, genuine race in the same page object, the other failure mode observed:
waitForResponseonly observes responses arriving after it is called, so a fast server answer was missed and the call timed out. The listener is now registered first, then the click, then the await — plus a wait for the row the AJAX handler appends.Verification
The jest suite previously ran only locally:
lint_and_testinstalled npm dependencies but went straight to PHPUnit.npm run test:unit-jsnow runs in CI, beforewp-envstarts so a JS regression fails in seconds instead of after the container boot.Two E2E failures seen, neither from this PR
Both were attributed by measurement, not assumption:
document-rich-formatting.spec.jsfails ~2 of 3 runs. Swapping inmain's version of both changed JS files reproduces the same 2-of-3 rate, so it is pre-existing. Cause: the spec clicks the Text tab and fills the textarea without waiting for TinyMCE to complete the asynchronous switch, sofill()races a hidden element. A fix was attempted here and reverted — driving the switch throughswitchEditorsmade it worse (5 of 5 failing), and hardening an unrelated spec does not belong in this change. Worth its own PR.document-revisions.spec.js:74failed once under full-suite load, passed on a second full run, and passes 12/12 in isolation. Non-deterministic under load; those tests take 38–60s each.Expect the rich-formatting spec to go red intermittently in CI regardless of this PR.
Coverage gap
The required-field validation this function guards has no direct E2E coverage — no spec exercises it end to end. The new unit tests cover the extraction contract, and the rich-content save specs caught the
getContent()attempt indirectly, but an E2E test for the validation flow itself would be worth adding.