Citizen allows stored XSS in search no result messages
Description
Citizen is a MediaWiki skin that makes extensions part of the cohesive experience. The citizen-search-noresults-title and citizen-search-noresults-desc system messages are inserted into raw HTML, allowing anybody who can edit those messages to insert arbitrary HTML into the DOM. This vulnerability is fixed in 3.3.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2025-49576: XSS in Citizen MediaWiki skin via unsanitized system messages, fixed in 3.3.1.
Vulnerability
CVE-2025-49576 is a stored cross-site scripting (XSS) vulnerability in the Citizen skin for MediaWiki. The skin inserts the system messages citizen-search-noresults-title and citizen-search-noresults-desc directly into raw HTML without proper sanitization [1]. This allows any user with permission to edit these system messages (typically administrators or users with editinterface rights) to inject arbitrary HTML and JavaScript into the page [1].
Exploitation
An attacker with the ability to edit the skin's system messages can craft a payload that executes in the browsers of all users viewing search results with no matches. No additional user interaction is required beyond visiting a page that triggers the search no-results display. The attacker does not need to be authenticated beyond having the editinterface right [1].
Impact
Successful exploitation leads to full XSS, enabling an attacker to perform actions on behalf of the victim, steal session cookies, redirect users to malicious sites, or deface the wiki. Since the payload is stored in the skin's configuration, it affects all visitors until removed [1].
Mitigation
The vulnerability has been patched in version 3.3.1 of the Citizen skin [1]. The fix was implemented in two commits: one that migrated the search typeahead to Mustache templates ([2]) and another that specifically addresses stored XSS vulnerabilities across multiple skin components ([3]). Users should upgrade to Citizen 3.3.1 or later. No workarounds are documented; the only mitigation is to restrict the editinterface permission to trusted users.
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
starcitizentools/citizen-skinPackagist | >= 2.31.0, < 3.3.1 | 3.3.1 |
Affected products
2- StarCitizenTools/mediawiki-skins-Citizenv5Range: >= a0296afaedbe1a277337a2d8f1da83cb3a79b9ab, < 93c36ac778397e0e7c46cf7adb1e5d848265f1bd
Patches
293c36ac77839fix(security): 🐛 🔒️ fix various stored XSS system message vulnerabilities
7 files changed · +23 −17
i18n/en.json+1 −1 modified@@ -116,6 +116,6 @@ "citizen-command-palette-tip-commands": "Type <code>/</code> for all commands", "citizen-command-palette-tip-users": "Type <code>@</code> to search for users", "citizen-command-palette-tip-namespace": "Type <code>:</code> to search for namespace", - "citizen-command-palette-tip-templates": "Type <code>{{</code> to search for templates", + "citizen-command-palette-tip-templates": "Type <code><nowiki>{{</nowiki></code> to search for templates", "citizen-command-palette-tip-actions": "Type <code>></code> for a list of actions" }
includes/Components/CitizenComponentUserInfo.php+10 −5 modified@@ -4,6 +4,7 @@ namespace MediaWiki\Skins\Citizen\Components; +use MediaWiki\Html\Html; use MediaWiki\Language\Language; use MediaWiki\MediaWikiServices; use MediaWiki\Title\MalformedTitleException; @@ -52,11 +53,15 @@ private function getUserRegistration(): ?array { return null; } - $html = sprintf( - '<time class="citizen-user-regdate" datetime="%s">%s</time>', - wfTimestamp( TS_ISO_8601, $timestamp ), - // Since this is not accessible by anon, we can use user language - $this->lang->userDate( $timestamp, $this->user ) + // Since this is not accessible by anon, we can use user language + $date = $this->lang->userDate( $timestamp, $this->user ); + $html = Html::element( + 'time', + [ + 'class' => 'citizen-user-regdate', + 'datetime' => wfTimestamp( TS_ISO_8601, $timestamp ) + ], + $date ); return [
resources/skins.citizen.commandPalette/components/CommandPaletteFooter.vue+4 −4 modified@@ -59,10 +59,10 @@ module.exports = exports = defineComponent( { // TODO: Make this expandable with more tips, probably with a mw hook // TODO: Maybe we should move this to store? const tips = [ - mw.message( 'citizen-command-palette-tip-commands' ).plain(), - mw.message( 'citizen-command-palette-tip-users' ).plain(), - mw.message( 'citizen-command-palette-tip-namespace' ).plain(), - mw.message( 'citizen-command-palette-tip-templates' ).plain() + mw.message( 'citizen-command-palette-tip-commands' ).parse(), + mw.message( 'citizen-command-palette-tip-users' ).parse(), + mw.message( 'citizen-command-palette-tip-namespace' ).parse(), + mw.message( 'citizen-command-palette-tip-templates' ).parse() ]; const currentTipIndex = ref( 0 );
resources/skins.citizen.preferences/addPortlet.polyfill.js+1 −1 modified@@ -15,7 +15,7 @@ function addDefaultPortlet( portlet ) { if ( label ) { const labelDiv = document.createElement( 'div' ); labelDiv.classList.add( 'citizen-menu__heading' ); - labelDiv.innerHTML = label.textContent || ''; + labelDiv.textContent = label.textContent || ''; portlet.insertBefore( labelDiv, label ); label.remove(); }
resources/skins.citizen.search/templates/TypeaheadPlaceholder.mustache+4 −4 modified@@ -1,11 +1,11 @@ -<div +<div class="citizen-typeahead-placeholder" id="citizen-typeahead-placeholder" {{#hidden}}hidden{{/hidden}} > <div class="citizen-typeahead-placeholder-content"> {{#icon}}<div class="citizen-typeahead-placeholder-icon citizen-ui-icon mw-ui-icon-wikimedia-{{.}}"></div>{{/icon}} - {{#title}}<div class="citizen-typeahead-placeholder-title">{{{.}}}</div>{{/title}} - {{#description}}<div class="citizen-typeahead-placeholder-description">{{{.}}}</div>{{/description}} + {{#title}}<div class="citizen-typeahead-placeholder-title">{{.}}</div>{{/title}} + {{#description}}<div class="citizen-typeahead-placeholder-description">{{.}}</div>{{/description}} </div> -</div> \ No newline at end of file +</div>
skin.json+1 −0 modified@@ -312,6 +312,7 @@ "class": "MediaWiki\\ResourceLoader\\CodexModule", "dependencies": [ "mediawiki.api", + "mediawiki.jqueryMsg", "mediawiki.language", "mediawiki.page.ready", "mediawiki.storage",
templates/Menu.mustache+2 −2 modified@@ -6,8 +6,8 @@ > {{#label}} <div class="citizen-menu__heading{{#label-class}} {{.}}{{/label-class}}"> - {{{.}}} + {{.}} </div> {{/label}} {{>MenuContents}} -</nav> \ No newline at end of file +</nav>
a0296afaedbefeat(search): ✨ migrate typeahead to Mustache template part 2
10 files changed · +69 −218
resources/skins.citizen.search/components/TypeaheadList.less+1 −7 modified@@ -1,17 +1,11 @@ -.citizen-typeahead-group { - margin-top: var( --space-xxs ); - margin-bottom: var( --space-xxs ); -} - .citizen-typeahead-list { margin: 0; list-style: none; .citizen-typeahead-group--chips & { display: flex; gap: var( --space-xxs ); - padding-right: var( --space-md ); - padding-left: var( --space-md ); + padding: var( --space-xs ) var( --space-md ); overflow-x: auto; -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */
resources/skins.citizen.search/components/TypeaheadPlaceholder.less+26 −0 added@@ -0,0 +1,26 @@ +.citizen-typeahead-placeholder { + &-content { + display: flex; + flex-direction: column; + place-items: center; + padding: var( --space-xxl ) var( --space-md ); + line-height: var( --line-height-xs ); + text-align: center; + } + + &-icon { + --size-icon: 4rem; + margin-bottom: var( --space-xs ); + } + + &-title { + font-size: var( --font-size-large ); + font-weight: var( --font-weight-medium ); + } + + &-description { + margin-top: 2px; + font-size: var( --font-size-small ); + color: var( --color-subtle ); + } +}
resources/skins.citizen.search/htmlHelper.js+0 −93 removed@@ -1,93 +0,0 @@ -/** - * @typedef {Object} TypeaheadItemData - * @property {string} id - * @property {string} type - * @property {string} link - * @property {string} icon - * @property {string} thumbnail - * @property {string} title - * @property {string} label - * @property {string} desc - */ - -/** - * Return an object containing functions to handle HTML elements for a typeahead component. - * - * @return {Object} An object with functions for creating, updating, and removing HTML elements for a typeahead component. - */ -function htmlHelper() { - return { - /** - * Generate menu item HTML using the existing template - * - * @param {TypeaheadItemData} data - * @return {HTMLElement|void} - */ - getItemElement: function ( data ) { - // TODO: Should we use template element or Mustache or just Javascript? - const template = document.getElementById( 'citizen-typeahead-template' ); - - // Shouldn't happen but just to be safe - if ( !( template instanceof HTMLTemplateElement ) ) { - return; - } - - const - fragment = template.content.cloneNode( true ), - item = fragment.querySelector( '.citizen-typeahead__item' ); - - this.updateItemElement( item, data ); - return fragment; - }, - /** - * Update typeahead item element - * - * @param {HTMLElement} item - * @param {TypeaheadItemData} data - */ - updateItemElement: function ( item, data ) { - if ( data.id ) { - item.setAttribute( 'id', data.id ); - } - if ( data.type ) { - item.classList.add( `citizen-typeahead__item-${ data.type }` ); - - if ( data.type !== 'placeholder' ) { - item.setAttribute( 'role', 'option' ); - } - } - - if ( data.size ) { - item.classList.add( `citizen-typeahead__item-${ data.size }` ); - } - if ( data.link ) { - item.querySelector( '.citizen-typeahead__content' ).setAttribute( 'href', data.link ); - } - if ( data.icon || data.thumbnail ) { - const thumbnail = item.querySelector( '.citizen-typeahead__thumbnail' ); - if ( data.thumbnail ) { - thumbnail.style.backgroundImage = `url('${ data.thumbnail }')`; - } else { - thumbnail.classList.add( - 'citizen-typeahead__thumbnail', - 'citizen-ui-icon', - `mw-ui-icon-wikimedia-${ data.icon }` - ); - } - } - if ( data.title ) { - // Required to use innerHTML because of highlightText - item.querySelector( '.citizen-typeahead__title' ).innerHTML = data.title; - } - if ( data.label ) { - item.querySelector( '.citizen-typeahead__label' ).innerHTML = data.label; - } - if ( data.desc ) { - item.querySelector( '.citizen-typeahead__description' ).textContent = data.desc; - } - } - }; -} - -/** @module htmlHelper */ -module.exports = htmlHelper;
resources/skins.citizen.search/searchPresults.js+8 −7 modified@@ -1,5 +1,4 @@ const config = require( './config.json' ); -const htmlHelper = require( './htmlHelper.js' )(); const searchHistory = require( './searchHistory.js' )( config ); function searchPresults() { @@ -27,20 +26,22 @@ function searchPresults() { document.getElementById( 'citizen-typeahead-list-history' ).outerHTML = templates.TypeaheadList.render( data, partials ).html(); document.getElementById( 'citizen-typeahead-group-history' ).hidden = false; }, - render: function ( typeaheadEl, templates ) { - typeaheadEl.querySelector( '.citizen-typeahead__item-placeholder' )?.remove(); + render: function ( templates ) { + const placeholderEl = document.getElementById( 'citizen-typeahead-placeholder' ); + placeholderEl.innerHTML = ''; + placeholderEl.hidden = true; + const historyResults = searchHistory.get(); if ( historyResults && historyResults.length > 0 ) { this.renderHistory( historyResults, templates ); } else { const data = { icon: 'articlesSearch', - type: 'placeholder', - size: 'lg', title: mw.message( 'searchsuggest-search' ).text(), - desc: mw.message( 'citizen-search-empty-desc' ).text() + description: mw.message( 'citizen-search-empty-desc' ).text() }; - typeaheadEl.append( htmlHelper.getItemElement( data ) ); + placeholderEl.innerHTML = templates.TypeaheadPlaceholder.render( data ).html(); + placeholderEl.hidden = false; } }, clear: function () {
resources/skins.citizen.search/searchResults.js+4 −6 modified@@ -1,5 +1,4 @@ // const config = require( './config.json' ); -const htmlHelper = require( './htmlHelper.js' )(); const searchAction = require( './searchAction.js' )(); /** @@ -82,15 +81,13 @@ function searchResults() { const regex = regexCache[ match ]; return title.replace( regex, '<span class="citizen-typeahead__highlight">$&</span>' ); }, - getPlaceholderHTML: function ( queryValue ) { + getPlaceholderHTML: function ( queryValue, templates ) { const data = { icon: 'articleNotFound', - type: 'placeholder', - size: 'lg', title: mw.message( 'citizen-search-noresults-title', queryValue ).text(), - desc: mw.message( 'citizen-search-noresults-desc' ).text() + description: mw.message( 'citizen-search-noresults-desc' ).text() }; - return htmlHelper.getItemElement( data ); + return templates.TypeaheadPlaceholder.render( data ).html(); }, getResultsHTML: function ( results, queryValue, templates ) { const items = []; @@ -133,6 +130,7 @@ function searchResults() { clear: function () { // TODO: This should not be here document.getElementById( 'citizen-typeahead-list-page' ).innerHTML = ''; + document.getElementById( 'citizen-typeahead-group-page' ).hidden = true; searchAction.clear(); }, init: function () {
resources/skins.citizen.search/skins.citizen.search.less+0 −84 modified@@ -2,8 +2,6 @@ @import '../mixins.less'; .citizen-typeahead { - display: flex; - flex-direction: column; border-bottom-right-radius: var( --border-radius-medium ); border-bottom-left-radius: var( --border-radius-medium ); @@ -40,71 +38,6 @@ } } - &__item { - &--hidden { - display: none; - } - - &-lg { - .citizen-typeahead { - &__content { - flex-direction: column; - padding-top: var( --space-xl ); - padding-bottom: var( --space-xl ); - text-align: center; - } - - &__title { - font-size: var( --font-size-medium ); - font-weight: var( --font-weight-semibold ); - } - - &__thumbnail { - margin-bottom: var( --space-sm ); - } - - &__description { - margin-top: 0.1rem; - } - } - } - } - - &__content { - display: flex; - align-items: center; - padding: var( --space-xs ) var( --space-md ); - color: var( --color-base ); - } - - &__thumbnail { - flex-shrink: 0; - width: 100%; - max-width: 60px; - height: 60px; - overflow: hidden; - - // Needed the specificity, we should refactor this - .citizen-typeahead &.citizen-ui-icon::before { - background-size: contain; - } - } - - &__text { - flex-grow: 1; - overflow: hidden; - } - - &__header { - display: flex; - justify-content: space-between; - } - - &__title { - flex-shrink: 0; - color: var( --color-emphasized ); - } - &__highlight { font-weight: var( --font-weight-medium ); color: var( --color-subtle ); @@ -115,30 +48,13 @@ color: var( --color-emphasized ); } - &__label { - display: flex; - gap: var( --space-xxs ); - font-size: var( --font-size-small ); - color: var( --color-base ); - } - &__labelItem { display: flex; gap: var( --space-xxs ); align-items: center; - } - - &__description, - &__labelItem { font-size: var( --font-size-x-small ); color: var( --color-subtle ); } - - &__title, - &__description { - overflow: hidden; - text-overflow: ellipsis; - } } // HACK: Hide default MW search suggestion if it somehow loaded
resources/skins.citizen.search/templates/TypeaheadElement.mustache+1 −15 modified@@ -1,18 +1,4 @@ <div id="searchform-suggestions" class="citizen-typeahead"> {{#array-lists}}{{>TypeaheadList}}{{/array-lists}} - {{! Template }} - <template id="citizen-typeahead-template"> - <div class="citizen-typeahead__item"> - <a href="" class="citizen-typeahead__content"> - <div class="citizen-typeahead__thumbnail"></div> - <div class="citizen-typeahead__text"> - <div class="citizen-typeahead__header"> - <div class="citizen-typeahead__title"></div> - <div class="citizen-typeahead__label"></div> - </div> - <div class="citizen-typeahead__description"></div> - </div> - </a> - </div> - </template> + {{#data-placeholder}}{{>TypeaheadPlaceholder}}{{/data-placeholder}} </div> \ No newline at end of file
resources/skins.citizen.search/templates/TypeaheadPlaceholder.mustache+11 −0 added@@ -0,0 +1,11 @@ +<div + class="citizen-typeahead-placeholder" + id="citizen-typeahead-placeholder" + {{#hidden}}hidden{{/hidden}} +> + <div class="citizen-typeahead-placeholder-content"> + {{#icon}}<div class="citizen-typeahead-placeholder-icon citizen-ui-icon mw-ui-icon-wikimedia-{{.}}"></div>{{/icon}} + {{#title}}<div class="citizen-typeahead-placeholder-title">{{{.}}}</div>{{/title}} + {{#description}}<div class="citizen-typeahead-placeholder-description">{{{.}}}</div>{{/description}} + </div> +</div> \ No newline at end of file
resources/skins.citizen.search/typeahead.js+12 −5 modified@@ -10,6 +10,7 @@ const searchResults = require( './searchResults.js' )(); const searchQuery = require( './searchQuery.js' )(); const templateTypeaheadElement = require( './templates/TypeaheadElement.mustache' ); +const templateTypeaheadPlaceholder = require( './templates/TypeaheadPlaceholder.mustache' ); const templateTypeaheadList = require( './templates/TypeaheadList.mustache' ); const templateTypeaheadListItem = require( './templates/TypeaheadListItem.mustache' ); @@ -298,18 +299,21 @@ const typeahead = { this.mustacheCompiler = mw.template.getCompiler( 'mustache' ); Object.assign( compiledTemplates, { TypeaheadElement: this.mustacheCompiler.compile( templateTypeaheadElement ), + TypeaheadPlaceholder: this.mustacheCompiler.compile( templateTypeaheadPlaceholder ), TypeaheadList: this.mustacheCompiler.compile( templateTypeaheadList ), TypeaheadListItem: this.mustacheCompiler.compile( templateTypeaheadListItem ) } ); const data = { + 'data-placeholder': { hidden: true }, 'array-lists': [ { type: 'action', class: 'citizen-typeahead-group--chips', hidden: true, historyValue: 'query' }, { type: 'history', hidden: true, keyboardNavigation: true }, { type: 'page', hidden: true, keyboardNavigation: true, historyValue: 'title' } ] }; const partials = { + TypeaheadPlaceholder: compiledTemplates.TypeaheadPlaceholder, TypeaheadList: compiledTemplates.TypeaheadList }; this.element = compiledTemplates.TypeaheadElement.render( data, partials ).get()[ 0 ]; @@ -322,7 +326,7 @@ const typeahead = { searchHistory.init(); searchResults.init(); - searchPresults.render( this.element, compiledTemplates ); + searchPresults.render( compiledTemplates ); // Init the value in case of undef error typeahead.items.set(); @@ -344,6 +348,8 @@ async function getSuggestions() { const renderSuggestions = ( results ) => { const groupEl = document.getElementById( 'citizen-typeahead-group-page' ); const listEl = document.getElementById( 'citizen-typeahead-list-page' ); + const placeholderEl = document.getElementById( 'citizen-typeahead-placeholder' ); + if ( results.length > 0 ) { // TODO: This should be a generic method listEl.outerHTML = searchResults.getResultsHTML( @@ -352,13 +358,14 @@ async function getSuggestions() { compiledTemplates ); groupEl.hidden = false; - typeahead.element.querySelector( '.citizen-typeahead__item-placeholder' )?.remove(); + placeholderEl.innerHTML = ''; + placeholderEl.hidden = true; } else { // Update placeholder with no result content listEl.innerHTML = ''; groupEl.hidden = true; - typeahead.element.querySelector( '.citizen-typeahead__item-placeholder' )?.remove(); - typeahead.element.append( searchResults.getPlaceholderHTML( searchQuery.valueHtml ) ); + placeholderEl.innerHTML = searchResults.getPlaceholderHTML( searchQuery.valueHtml, compiledTemplates ); + placeholderEl.hidden = false; } typeahead.form.setLoadingState( false ); @@ -404,7 +411,7 @@ function updateTypeaheadItems() { getSuggestions(); } else { searchResults.clear(); - searchPresults.render( typeahead.element, compiledTemplates ); + searchPresults.render( compiledTemplates ); } typeahead.items.set(); }
skin.json+6 −1 modified@@ -203,6 +203,7 @@ "es6": true, "styles": [ "resources/skins.citizen.search/skins.citizen.search.less", + "resources/skins.citizen.search/components/TypeaheadPlaceholder.less", "resources/skins.citizen.search/components/TypeaheadList.less", "resources/skins.citizen.search/components/TypeaheadListItem.less" ], @@ -218,6 +219,11 @@ "file": "resources/skins.citizen.search/templates/TypeaheadElement.mustache", "type": "text" }, + { + "name": "resources/skins.citizen.search/templates/TypeaheadPlaceholder.mustache", + "file": "resources/skins.citizen.search/templates/TypeaheadPlaceholder.mustache", + "type": "text" + }, { "name": "resources/skins.citizen.search/templates/TypeaheadList.mustache", "file": "resources/skins.citizen.search/templates/TypeaheadList.mustache", @@ -228,7 +234,6 @@ "file": "resources/skins.citizen.search/templates/TypeaheadListItem.mustache", "type": "text" }, - "resources/skins.citizen.search/htmlHelper.js", "resources/skins.citizen.search/fetch.js", "resources/skins.citizen.search/searchAction.js", "resources/skins.citizen.search/searchClient.js",
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-86xf-2mgp-gv3gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-49576ghsaADVISORY
- github.com/StarCitizenTools/mediawiki-skins-Citizen/commit/93c36ac778397e0e7c46cf7adb1e5d848265f1bdghsax_refsource_MISCWEB
- github.com/StarCitizenTools/mediawiki-skins-Citizen/commit/a0296afaedbe1a277337a2d8f1da83cb3a79b9abghsax_refsource_MISCWEB
- github.com/StarCitizenTools/mediawiki-skins-Citizen/security/advisories/GHSA-86xf-2mgp-gv3gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.