Skip to content

JS DOM-safe rendering: stop string-concat HTML into the toolbar iframe#1074

Merged
dereuromark merged 2 commits into
5.xfrom
js-dom-safe-rendering
May 14, 2026
Merged

JS DOM-safe rendering: stop string-concat HTML into the toolbar iframe#1074
dereuromark merged 2 commits into
5.xfrom
js-dom-safe-rendering

Conversation

@dereuromark
Copy link
Copy Markdown
Member

Summary

Third followup PR from the 5.x audit -- the JS hygiene pass.

The toolbar iframe is loaded same-origin with the host app, so any HTML injection inside it is effectively XSS against the app being developed (cookies, CSRF tokens, in-flight session). Several panels were composing HTML by string concatenation from data that crosses the toolbar's trust boundary (cross-origin response Content-Type headers, attacker-influenced URLs, raw composer error bodies). Each sink is rewritten to construct DOM via document.createElement / jQuery .text() / .append() instead.

What changed

  • HistoryPanel.js -- previously the per-XHR row was built by string-replacing six placeholders ({id}, {time}, {method}, {status}, {type}, {url}) into raw HTML and inserting the result with .after(htmlString). params.type is the response Content-Type of any cross-origin URL the dev's app fetches and is therefore attacker-controlled; params.url and params.method are similarly untrusted. New flow: substitute only the (sanitized) request UUID {id} into the href and data-request attributes, then populate the five visible spans via jQuery .text(). The [data-request=...] highlight selector at the bottom of init() is rewritten as a .filter(fn) predicate so a runtime id can't slip into selector syntax.

  • CachePanel.js / RequestPanel.js -- $(

    ${text}

    ) -> $('<p>').text(text). Same UX, no HTML parsing.

  • PackagesPanel.js -- the success / error output is assembled as jQuery-built elements with .text(), and showMessage() writes them via .empty().append(...) rather than .html(string). Fixes a pre-existing bug along the way: the error callback was passing jQuery's textStatus string to buildErrorMessage (which expected the jqXHR object), so every non-2xx response was previously a silent JSON.parse throw with no visible message. The new error builder takes the jqXHR object directly and falls back to responseText / statusText / a literal "Request failed" -- credit to codex for catching the regression.

  • postMessage handlers in inject-iframe.js (parent side) and Toolbar.onMessage (iframe side) now reject any message whose origin is not same-origin and whose source is not the expected counterpart window (iframe.contentWindow on the parent, window.parent on the iframe). Today only resize and XHR-counter messages flow, so the practical impact of the missing check is low, but the one-line hardening keeps the protocol safe as it grows.

Verification

ESLint output for the touched files is clean; the remaining warnings/errors (Turbo undefined, unnamed function expressions in Toolbar and inject-iframe, Keyboard.js return) are pre-existing on 5.x and untouched by this PR. PHP test suite, PHPStan, and PHPCS all green.

The toolbar iframe is loaded same-origin with the host app, so any HTML
injection inside it is effectively XSS against the app being developed.
Several panels were composing HTML by string concatenation from data that
crosses the toolbar's trust boundary (cross-origin response headers,
attacker-influenced URLs, raw composer error bodies). Replace each sink
with DOM construction via `document.createElement` / jQuery `.text()` /
`.append()`.

* HistoryPanel: build each row by parsing the template once, substituting
  only the (sanitized) UUID `{id}` into the href/data-request attributes,
  and populating method/status/type/url/time via `.text()`. Replaces the
  prior pattern that interpolated all six placeholders raw into HTML and
  parsed the result with `.after(string)`. Also rewrites the
  `[data-request=...]` selector as a `.filter()` predicate so a runtime
  request id can't slip into selector syntax.

* CachePanel / RequestPanel `addMessage`: switch
  `$(`<p>${text}</p>`)` to `$('<p>').text(text)`.

* PackagesPanel: assemble the success / error output as jQuery-built
  elements with `.text()`, and `showMessage()` clears the terminal with
  `.empty().append(...)` rather than `.html(string)`. Fixes a pre-existing
  bug along the way: the error callback was passing the jQuery `textStatus`
  argument to `buildErrorMessage` (which expected an XHR object), so every
  non-2xx response was previously a silent JSON.parse throw. The error
  builder now takes the jqXHR object directly and falls back to
  `responseText` / `statusText` / a literal "Request failed".

* postMessage handlers in `inject-iframe.js` and `Toolbar.onMessage` now
  reject any message whose origin is not same-origin and whose source is
  not the expected counterpart window (the iframe contentWindow on the
  parent side, `window.parent` on the iframe side). Today only resize and
  XHR-counter messages flow, so the practical impact of the missing check
  is low, but the one-line hardening keeps the protocol safe as it grows.

ESLint output for the touched files is clean; the remaining lint
warnings/errors (Turbo undefined, unnamed function expressions in Toolbar
and inject-iframe, Keyboard.js return) are pre-existing on 5.x.
@dereuromark dereuromark added this to the 5.x milestone May 13, 2026
@dereuromark dereuromark marked this pull request as draft May 13, 2026 23:22
Copy link
Copy Markdown
Contributor

@LordSimal LordSimal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@dereuromark dereuromark marked this pull request as ready for review May 14, 2026 20:22
@dereuromark dereuromark merged commit 99f97d0 into 5.x May 14, 2026
9 checks passed
@dereuromark dereuromark deleted the js-dom-safe-rendering branch May 14, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants