Bug #14772
openWt.js: getInlineStyleSheet() adopts the first <style> in the document, not Wt's own
0%
Description
The following is a bug report along with a proposed fix that Claude Code found while we were testing multiple browsers.
Affects: 4.11.2 → 4.14.0 (code is byte-identical across all of them; verified at tag 4.14.0, commit 77ffe79a)
Component: src/web/skeleton/Wt.js
Browser: Firefox (fatal). Chromium-based browsers hit the same defect but usually only mis-target rules rather than throwing.
Summary¶
WT.addCss() writes into whatever stylesheet getInlineStyleSheet() returns, and that function selects the first <style> element in the document rather than Wt's own #Wt-inline-css. Any <style> that Wt did not create — most commonly one injected by a browser extension — is silently adopted.
When the adopted sheet belongs to an extension, Firefox treats it as cross-origin. Reading .cssRules throws SecurityError, the exception propagates out of WtLoadWidgetTree, and the application renders a blank page.
This is not a niche configuration: extensions that inject <style> elements (Dark Reader, Stylus, various accessibility and ad-blocking add-ons) are installed by a large fraction of Firefox users. From the user's point of view the Wt app is simply broken, with nothing on screen and no error message.
Actual behaviour¶
Blank page. Console:
Uncaught DOMException: CSSStyleSheet.cssRules getter: Not allowed to access cross-origin stylesheet
addCss https://<host>/ line 27 > injectedScript:18
WtLoadWidgetTree https://<host>/ line 27 > injectedScript:29
load https://<host>/ line 27 > injectedScript:18
<anonymous> https://<host>/ line 27 > injectedScript:151
ready https://<host>/ line 27 > injectedScript:6
<anonymous> https://<host>/ line 27 > injectedScript:151
jsLoaded https://<host>/ line 27 > injectedScript:18
onload https://<host>/ line 27 > injectedScript:18
Expected behaviour¶
Wt writes its rules into the stylesheet it owns, and ignores stylesheets belonging to anything else. A foreign <style> in the document should not affect rendering.
Steps to reproduce¶
A. The real-world failure (Firefox)¶
- Build any Wt application that adds a CSS rule at startup:
- Install any extension that injects a
<style>element into the page — Dark Reader is the easiest — and enable it for the site. - Load the application in Firefox.
Result: blank page and the exception above.
Control: restart Firefox in Troubleshoot Mode (Help → Troubleshoot Mode, which disables extensions) and the same page renders correctly. This was the confirming test in my case.
B. Deterministic repro of the underlying ownership bug, no extension required¶
Add a foreign <style> to head-matter in wt_config.xml, which is emitted before the skeleton's #Wt-inline-css:
Load the app and inspect the DOM. Wt's rules land in #not-wt; #Wt-inline-css stays empty. No exception here — because this sheet is same-origin — but it demonstrates the selection flaw directly, and it makes Wt's inline rules subject to the lifetime and specificity ordering of a stylesheet it does not control.
Root cause¶
src/web/skeleton/Wt.js:1705 (4.14.0):
function getInlineStyleSheet() {
if (!inlineStyleSheet) {
const ds = document.styleSheets;
for (let i = 0, il = ds.length; i < il; ++i) {
const s = ds[i];
if (WT.hasTag(ds[i].ownerNode, "STYLE")) { // any <style> will do
inlineStyleSheet = s;
break;
}
}
...
and src/web/skeleton/Wt.js:1727:
this.addCss = function(selector, style) {
const s = getInlineStyleSheet();
// strange error with IE9 when in iframe
const pos = s.cssRules ? s.cssRules.length : 0; // throws here
s.insertRule(selector + " { " + style + " }", pos);
};
Two things are worth noting:
-
The element Wt actually owns already has a stable id.
addCssText()twenty lines below looks it up correctly withdocument.getElementById("Wt-inline-css"), and both skeletons emit it unconditionally —src/web/skeleton/Boot.html:23andsrc/web/skeleton/Hybrid.html:18.getInlineStyleSheet()just doesn't use it. -
The existing
s.cssRules ? … : 0guard cannot help. Reading the property is what throws, so the exception is raised while evaluating the ternary's condition — it never reaches either branch.
git log -L 1705,1731 shows these lines were last touched in 2022 by 8d24225e ("WT-10967: fix eslint warnings in Wt.js"), i.e. cosmetically. The logic predates it.
Suggested fix¶
Prefer the element Wt owns, and treat any sheet whose rules cannot be read as not ours:
function getInlineStyleSheet() {
if (!inlineStyleSheet) {
const own = document.getElementById("Wt-inline-css");
if (own && own.sheet) {
inlineStyleSheet = own.sheet;
} else {
const ds = document.styleSheets;
for (let i = 0, il = ds.length; i < il; ++i) {
const s = ds[i];
if (!WT.hasTag(s.ownerNode, "STYLE")) {
continue;
}
try {
s.cssRules;
} catch (e) {
continue; // cross-origin (e.g. extension-injected): not ours
}
inlineStyleSheet = s;
break;
}
}
if (!inlineStyleSheet) {
const s = document.createElement("style");
document.getElementsByTagName("head")[0].appendChild(s);
inlineStyleSheet = s.sheet;
}
}
return inlineStyleSheet;
}
The getElementById path handles every normal deployment. The scan is kept as a fallback for embedded or custom-skeleton cases, now skipping sheets it cannot read. The existing create-a-new-<style> fallback still catches everything else, so there is no path where addCss can be handed an unusable sheet.
this.getCssRule() a little further down iterates document.styleSheets and reads .cssRules on every sheet, so it looks like it can fail the same way and may deserve the same try/catch treatment.
Severity¶
Suggest treating this as higher than cosmetic. It is a blank page rather than a degraded one, it depends on client-side state the developer cannot see or control, and it is invisible in testing unless the developer happens to run the same extension. My own application ran this way in production and I only found it because I opened it in a different browser.
RM Updated by Romain Mardulyn 16 days ago
- Target version set to 4.15.0
ED Updated by emil de keyser 14 days ago
- Status changed from New to InProgress
- Assignee set to emil de keyser
ED Updated by emil de keyser 7 days ago
- Status changed from InProgress to Review
- Assignee deleted (
emil de keyser)
RM Updated by Romain Mardulyn 1 day ago
- Assignee set to Romain Mardulyn
RM Updated by Romain Mardulyn 1 day ago
- Status changed from Review to Resolved
- Assignee changed from Romain Mardulyn to emil de keyser