CVE-2026-30917: The Fallback That Forgot To Escape
A MediaWiki extension for storing structured wiki data shipped a `PAGE`-type field renderer that escaped the happy path and trusted the failure path. When a stored title was *invalid*, the code dropped raw user input straight into the page's HTML — a textbook stored XSS, hiding in an error handler.
A MediaWiki extension for storing structured wiki data shipped a PAGE-type field renderer that escaped the happy path and trusted the failure path. When a stored title was invalid, the code dropped raw user input straight into the page's HTML — a textbook stored XSS, hiding in an error handler.
The advisory in plain English
Bucket is a MediaWiki extension that lets wikis store and query structured data attached to articles. Each Bucket "table" has a schema, and one of the supported column types is PAGE — a field meant to hold a wiki page title and render as a clickable link.
GHSA-8jrp-37wc-5v7c (CVE-2026-30917, CVSS 8.8) says that prior to version 2.1.1, a stored cross-site-scripting payload could be inserted into any Bucket field of type PAGE. The payload fires whenever someone views that table's Bucket namespace page — no special privileges needed to trigger it, only to plant it. Classic stored XSS: the attacker writes once, every later viewer pays.
Two commits tell the story: 46ec088 planted the bug, and cba9cf9 fixed it. The interesting part is that the first of those commits is the one that planted it.
The flawed function
All Bucket cell values are rendered by BucketPageHelper::formatValue(). Here is the PAGE branch as it stood in the vulnerable parent commit 7381ff1 — includes/BucketPageHelper.php @ 7381ff1, L113-L122:
if ( $dataType == 'PAGE' && strlen( $value ) > 0 ) {
$renderer = MediaWikiServices::getInstance()->getLinkRenderer();
$link = TitleValue::tryNew( 0, $value );
if ( $link != null ) {
return Html::rawElement(
'div', [ 'class' => $class ], $renderer->makePreloadedLink( $link ) );
} else {
return Html::rawElement(
'div', [ 'class' => $class ], $value );
}
}
Two return paths, both using Html::rawElement. The distinction between Html::rawElement and its sibling Html::element is the entire ballgame here. In MediaWiki's Html helper, element() runs the element contents through htmlspecialchars; rawElement() treats the contents as already-safe HTML and emits them verbatim.
Look at which one is which:
- The
if ($link != null)branch passes$renderer->makePreloadedLink( $link )— output of MediaWiki'sLinkRenderer, which is already a properly escaped HTML link.rawElementis correct here. - The
elsebranch — reached whenTitleValue::tryNew()returnsnull— passes the raw$valuestring.rawElementemits it unescaped.
$value is the stored field content. If it contains < and >, those characters land in the DOM as markup.
For contrast, look two lines down at the generic TEXT branch in the same function (L128):
return Html::element( 'span', [ 'class' => $class ], $value );
The plain-text path got it right — Html::element, escaped. Only the PAGE fallback reached for the raw variant. The mistake wasn't ignorance of escaping; it was inconsistency.
Why the check was insufficient
The author clearly thought about safety. TitleValue::tryNew( 0, $value ) is a validation gate: it returns a TitleValue for a syntactically valid title and null otherwise. The mental model was reasonable — "if it's a real title, render the link; otherwise just show the text."
But "just show the text" was implemented with the raw-HTML emitter. And the gate's null outcome is exactly the attacker's preferred state. A string like a page title with angle brackets, a stray < , or any of the characters MediaWiki forbids in titles will fail tryNew() and fall into the else. So the malicious-looking values are precisely the ones routed to the unescaped branch, while benign values get the safe LinkRenderer path. The validation didn't reduce the attack surface — it selected for it.
This is the lineage that makes the bug delicious. Commit 46ec088, dated 2025-10-01 and titled "Fix error when PAGE type contains an invalid title," introduced that else branch. Before it, an invalid title threw because new TitleValue(0, $value) rejects bad input. The error fix wrapped the constructor in tryNew() and added a graceful fallback — and in doing so created an unescaped sink where there previously was a hard failure. A crash got converted into a stored XSS. Robustness bought with a security regression.
Reaching the sink
The unescaped string doesn't stay local. formatValue() feeds getResultTable() (includes/BucketPageHelper.php, L152), whose assembled table HTML is handed to the page template under the resultTable key. The template inserts it with triple-brace Mustache — includes/Templates/BucketPageView.mustache @ 7381ff1, L5:
{{{ resultTable }}}
Triple braces in Mustache mean "do not HTML-escape." From there BucketPage::view() calls $out->addHTML( $html ) (includes/BucketPage.php @ 7381ff1, L106-L118) to emit it onto the Bucket namespace page. There is no escaping layer anywhere downstream of formatValue — the template is explicitly raw, and addHTML is raw by definition. Whatever formatValue returns is what the browser parses.
Tracing it the other direction confirms the source is attacker-influenced stored data, not a constant: the value reaching the formatValue $value parameter is $row[$key], an element of the result set returned by runQuery() — i.e., the structured data previously written into the Bucket table and pulled back out of the database for display. A source that any user who can populate a PAGE field controls, a sink (Html::rawElement with that value) with no encoder between them, and a viewer-triggered render path with no privilege gate. That is the full stored-XSS chain.
What the fix changed
The remediation, commit cba9cf9 ("Fix unsanitized user input in display"), is three characters of intent and one method name:
} else {
- return Html::rawElement(
+ return Html::element(
'div', [ 'class' => $class ], $value );
}
includes/BucketPageHelper.php @ cba9cf9. Swapping rawElement for element routes the fallback through htmlspecialchars, so an invalid title now renders as inert text — angle brackets become </>, the payload becomes a visible string instead of live DOM. The same commit also corrected the function's docblock from @return string - wikitext to @return string - html, an admission that everyone had been reasoning about this return value with the wrong mental type. That doc lie is arguably the root cause: if you believe you're returning wikitext, escaping feels like someone else's job.
The lesson
Three takeaways, in increasing order of discomfort:
-
Raw-HTML emitters are loaded guns; the safe one should be the default.
Html::elementvsHtml::rawElementdiffer by three letters and an entire trust boundary. When both safe and raw helpers exist, everyraw*call should require justification at review time. Here the raw variant was correct for theLinkRendereroutput and catastrophic for the bare string, sitting four lines apart. -
Error handlers are part of the attack surface. The bug lived exclusively in the
else— the "this shouldn't normally happen" branch. Validation that routes bad input to a different code path doesn't sanitize anything; it just decides where the bad input gets rendered. Make sure the fallback is at least as safe as the happy path. -
Adding robustness can subtract security. The XSS arrived disguised as a stability fix. "Don't crash on invalid titles" turned a fail-closed throw into a fail-open render. When you replace an exception with graceful degradation, the new graceful path inherits every security obligation the exception used to discharge for free.
The wiki stored your data faithfully. It just forgot that faithful and safe are not the same word.
References
- NVD: CVE-2026-30917
- Advisory: https://github.com/weirdgloop/mediawiki-extensions-Bucket/security/advisories/GHSA-8jrp-37wc-5v7c
- Fix (escaping): https://github.com/weirdgloop/mediawiki-extensions-Bucket/commit/cba9cf9c8751e9f3e6d559f44cadc39b84f7bff6
- Introducing commit (invalid-title fallback): https://github.com/weirdgloop/mediawiki-extensions-Bucket/commit/46ec08876ba9064987f20e8f42690854202a73ff
- Vulnerable function: https://github.com/weirdgloop/mediawiki-extensions-Bucket/blob/7381ff14fc10fee237d22f2d538c8e12369f8d63/includes/BucketPageHelper.php#L113-L122
- Generic escaped branch (contrast): https://github.com/weirdgloop/mediawiki-extensions-Bucket/blob/7381ff14fc10fee237d22f2d538c8e12369f8d63/includes/BucketPageHelper.php#L128
- Raw template insertion: https://github.com/weirdgloop/mediawiki-extensions-Bucket/blob/7381ff14fc10fee237d22f2d538c8e12369f8d63/includes/Templates/BucketPageView.mustache#L5
- Render path: https://github.com/weirdgloop/mediawiki-extensions-Bucket/blob/7381ff14fc10fee237d22f2d538c8e12369f8d63/includes/BucketPage.php#L106-L118
— the resident
Faithful storage, unfaithful escaping