Citizen allows stored XSS in preference menu headings
Description
Citizen is a MediaWiki skin that makes extensions part of the cohesive experience. Various preferences 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.
Stored XSS in Citizen MediaWiki skin via unsanitized preferences messages allows editors to inject arbitrary HTML; fixed in 3.3.1.
The Citizen skin for MediaWiki contains a stored cross-site scripting (XSS) vulnerability in its handling of user-editable system messages. Specifically, various preferences messages are inserted into raw HTML without proper sanitization, allowing any user who can edit those messages to inject arbitrary HTML into the DOM [1]. This occurs because the skin directly interpolates message content into the page markup rather than escaping it.
Exploitation requires the ability to edit MediaWiki system messages (typically users with the 'editinterface' permission, such as administrators). An attacker can modify a preferences message (e.g., theme labels, font size descriptions) to include malicious HTML or JavaScript. When other users view the preferences page or interact with the skin's UI, the injected code executes in their browser session [3].
Successful exploitation leads to cross-site scripting, enabling the attacker to perform actions on behalf of the victim, steal session cookies, redirect users to malicious sites, or deface the wiki. The impact is limited to users who can edit system messages, but the consequences can affect all visitors to the wiki.
The vulnerability is fixed in Citizen version 3.3.1. The security commit [3] addresses the issue by using MediaWiki's Html::element() and other escaping mechanisms to ensure message content is properly sanitized before rendering. Users are strongly advised to upgrade to the latest version to mitigate the risk.
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.13.0, < 3.3.1 | 3.3.1 |
Affected products
2- StarCitizenTools/mediawiki-skins-Citizenv5Range: >= a741639085d70c22a9f49890542a142a223bf981, < 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>
a741639085d7feat(core): ✨ move theme preferences to clientPrefs
13 files changed · +616 −162
i18n/en.json+8 −5 modified@@ -43,12 +43,15 @@ "citizen-tagline-ns-help": "Help page", "citizen-tagline-ns-category": "Category page", "citizen-tagline-user-regdate": "Joined $1", - "prefs-citizen-theme-label": "Theme", - "prefs-citizen-theme-option-auto": "Auto", - "prefs-citizen-theme-option-light": "Light", - "prefs-citizen-theme-option-dark": "Dark", "prefs-citizen-fontsize-label": "Font size", "prefs-citizen-pagewidth-label": "Page width", "prefs-citizen-lineheight-label": "Line height", - "prefs-citizen-resetbutton-label": "Reset to default" + "prefs-citizen-resetbutton-label": "Reset to default", + + "skin-theme-name": "Color", + "skin-theme-description": "Reduces the light emitted by device screens.", + "skin-theme-day-label": "Day", + "skin-theme-night-label": "Night", + "skin-theme-os-label": "Automatic", + "skin-theme-exclusion-notice": "This page is always in day mode." }
i18n/qqq.json+5 −5 modified@@ -46,12 +46,12 @@ "citizen-tagline-ns-help": "Tagline for pages in help namespace", "citizen-tagline-ns-category": "Tagline for pages in category namespace", "citizen-tagline-user-regdate": "Label for registration date in taglines on userpages", - "prefs-citizen-theme-label": "Tooltip for the theme dropdown in Special:Preferences", - "prefs-citizen-theme-option-auto": "Label for the auto theme option", - "prefs-citizen-theme-option-light": "Label for the light theme option", - "prefs-citizen-theme-option-dark": "Label for the dark theme option", "prefs-citizen-fontsize-label": "Label for the font size settings", "prefs-citizen-pagewidth-label": "Label for the page width settings", "prefs-citizen-lineheight-label": "Label for the line height settings", - "prefs-citizen-resetbutton-label": "Label for the reset button that restore default settings" + "prefs-citizen-resetbutton-label": "Label for the reset button that restore default settings", + "skin-theme-name": "Label for setting that allows you to change theme.", + "skin-theme-description": "Description for theme.", + "skin-theme-day-label": "Label for light theme (standard mode).", + "skin-theme-night-label": "Label for night theme (dark mode)." }
includes/Hooks/SkinHooks.php+1 −0 modified@@ -67,6 +67,7 @@ public function onBeforePageDisplay( $out, $skin ): void { $script = file_get_contents( MW_INSTALL_PATH . '/skins/Citizen/resources/skins.citizen.scripts/inline.js' ); $script = Html::inlineScript( $script ); + // TODO: Consider turning on cache after this is more stable $script = RL\ResourceLoader::filter( 'minify-js', $script, [ 'cache' => false ] ); $out->addHeadItem( 'skin.citizen.inline', $script ); }
includes/Partials/Theme.php+12 −1 modified@@ -25,6 +25,12 @@ namespace MediaWiki\Skins\Citizen\Partials; +const CLIENTPREFS_THEME_MAP = [ + 'auto' => 'os', + 'light' => 'day', + 'dark' => 'night' +]; + /** * Theme switcher partial of Skin Citizen */ @@ -42,7 +48,12 @@ public function setSkinTheme( array &$options ) { // Set theme to site theme $theme = $this->getConfigValue( 'CitizenThemeDefault' ) ?? 'auto'; - // Add HTML class based on theme set + // Legacy class to be deprecated $out->addHtmlClasses( 'skin-citizen-' . $theme ); + + // Add HTML class based on theme set + if ( CLIENTPREFS_THEME_MAP[ $theme ] ) { + $out->addHtmlClasses( 'skin-theme-clientpref-' . $theme ); + } } }
resources/skins.citizen.preferences/addPortlet.polyfill.js+88 −0 added@@ -0,0 +1,88 @@ +/** + * TODO: Revisit when we move to MW 1.43 and the interface is more stable + */ + +/** + * Creates default portlet. + * Based on Vector + * + * @param {Element} portlet + * @return {Element} + */ +function addDefaultPortlet( portlet ) { + const ul = portlet.querySelector( 'ul' ); + if ( !ul ) { + return portlet; + } + ul.classList.add( 'citizen-menu__content-list' ); + const label = portlet.querySelector( 'label' ); + if ( label ) { + const labelDiv = document.createElement( 'div' ); + labelDiv.classList.add( 'citizen-menu__heading' ); + labelDiv.innerHTML = label.textContent || ''; + portlet.insertBefore( labelDiv, label ); + label.remove(); + } + let wrapper = portlet.querySelector( 'div:last-child' ); + if ( wrapper ) { + ul.remove(); + wrapper.appendChild( ul ); + wrapper.classList.add( 'citizen-menu__content' ); + } else { + wrapper = document.createElement( 'div' ); + wrapper.classList.add( 'citizen-menu__content' ); + ul.remove(); + wrapper.appendChild( ul ); + portlet.appendChild( wrapper ); + } + portlet.classList.add( 'citizen-menu' ); + return portlet; +} + +/** + * Polyfill for mw.util.addPortlet for < MW 1.42 + * + * @return {Element} + */ +function addPortlet() { + if ( mw.util.addPortlet ) { + return addDefaultPortlet( mw.util.addPortlet ); + } + + return function ( id, label, before ) { + const portlet = document.createElement( 'div' ); + portlet.classList.add( 'mw-portlet', 'mw-portlet-' + id, 'emptyPortlet', + // Additional class is added to allow skins to track portlets added via this mechanism. + 'mw-portlet-js' + ); + portlet.id = id; + if ( label ) { + const labelNode = document.createElement( 'label' ); + labelNode.textContent = label; + portlet.appendChild( labelNode ); + } + const listWrapper = document.createElement( 'div' ); + const list = document.createElement( 'ul' ); + listWrapper.appendChild( list ); + portlet.appendChild( listWrapper ); + if ( before ) { + let referenceNode; + try { + referenceNode = document.querySelector( before ); + } catch ( e ) { + // CSS selector not supported by browser. + } + if ( referenceNode ) { + const parentNode = referenceNode.parentNode; + parentNode.insertBefore( portlet, referenceNode ); + } else { + return null; + } + } + mw.hook( 'util.addPortlet' ).fire( portlet, before ); + return addDefaultPortlet( portlet ); + }; +} + +/** @module addPortlet */ +module.exports = addPortlet;
resources/skins.citizen.preferences/clientPreferences.js+348 −0 added@@ -0,0 +1,348 @@ +/** + * @typedef {Object} ClientPreference + * @property {string[]} options that are valid for this client preference + * @property {string} preferenceKey for registered users. + * @property {string} [type] defaults to radio. Supported: radio, switch + * @property {Function} [callback] callback executed after a client preference has been modified. + */ + +/** + * @typedef {Object} PreferenceOption + * @property {string} label + * @property {string} value + */ + +const addPortlet = require( './addPortlet.polyfill.js' )(); +const clientPrefs = require( './clientPrefs.polyfill.js' )(); + +/** + * Get the list of client preferences that are active on the page, including hidden. + * + * @return {string[]} of active client preferences + */ +function getClientPreferences() { + return Array.from( document.documentElement.classList ).filter( + ( className ) => className.match( /-clientpref-/ ) + ).map( ( className ) => className.split( '-clientpref-' )[ 0 ] ); +} + +/** + * Check if the feature is excluded from the current page. + * + * @param {string} featureName + * @return {boolean} + */ +function isFeatureExcluded( featureName ) { + return document.documentElement.classList.contains( featureName + '-clientpref-excluded' ); +} + +/** + * Get the list of client preferences that are active on the page and not hidden. + * + * @param {Record<string,ClientPreference>} config + * @return {string[]} of user facing client preferences + */ +function getVisibleClientPreferences( config ) { + const active = getClientPreferences(); + // Order should be based on key in config.json + return Object.keys( config ).filter( ( key ) => active.indexOf( key ) > -1 ); +} + +/** + * @param {string} featureName + * @param {string} value + * @param {Record<string,ClientPreference>} config + */ +function toggleDocClassAndSave( featureName, value, config ) { + const pref = config[ featureName ]; + const callback = pref.callback || ( () => {} ); + clientPrefs.set( featureName, value ); + callback(); +} + +/** + * @param {string} featureName + * @param {string} value + * @return {string} + */ +const getInputId = ( featureName, value ) => `skin-client-pref-${ featureName }-value-${ value }`; + +/** + * @param {string} type + * @param {string} featureName + * @param {string} value + * @return {HTMLInputElement} + */ +function makeInputElement( type, featureName, value ) { + const input = document.createElement( 'input' ); + const name = `skin-client-pref-${ featureName }-group`; + const id = getInputId( featureName, value ); + input.name = name; + input.id = id; + input.type = type; + if ( type === 'checkbox' ) { + input.checked = value === '1'; + } else { + input.value = value; + } + input.setAttribute( 'data-event-name', id ); + return input; +} + +/** + * @param {string} featureName + * @param {string} value + * @return {HTMLLabelElement} + */ +function makeLabelElement( featureName, value ) { + const label = document.createElement( 'label' ); + // eslint-disable-next-line mediawiki/msg-doc + label.textContent = mw.msg( `${ featureName }-${ value }-label` ); + label.setAttribute( 'for', getInputId( featureName, value ) ); + return label; +} + +/** + * Create an element that informs users that a feature is not functional + * on a given page. This message is hidden by default and made visible in + * CSS if a specific exclusion class exists. + * + * @param {string} featureName + * @return {HTMLElement} + */ +function makeExclusionNotice( featureName ) { + const p = document.createElement( 'p' ); + // eslint-disable-next-line mediawiki/msg-doc + const noticeMessage = mw.message( `${ featureName }-exclusion-notice` ); + p.classList.add( 'exclusion-notice', `${ featureName }-exclusion-notice` ); + p.textContent = noticeMessage.text(); + return p; +} + +/** + * @param {Element} parent + * @param {string} featureName + * @param {string} value + * @param {string} currentValue + * @param {Record<string,ClientPreference>} config + */ +function appendRadioToggle( parent, featureName, value, currentValue, config ) { + const input = makeInputElement( 'radio', featureName, value ); + // input.classList.add( 'cdx-radio__input' ); + input.classList.add( 'citizen-client-prefs-radio__input' ); + if ( currentValue === value ) { + input.checked = true; + } + + if ( isFeatureExcluded( featureName ) ) { + input.disabled = true; + } + + const icon = document.createElement( 'span' ); + // icon.classList.add( 'cdx-radio__icon' ); + icon.classList.add( 'citizen-client-prefs-radio__icon' ); + const label = makeLabelElement( featureName, value ); + // label.classList.add( 'cdx-radio__label' ); + label.classList.add( 'citizen-client-prefs-radio__label' ); + const container = document.createElement( 'div' ); + // container.classList.add( 'cdx-radio' ); + container.classList.add( 'citizen-client-prefs-radio' ); + container.appendChild( input ); + container.appendChild( icon ); + container.appendChild( label ); + parent.appendChild( container ); + input.addEventListener( 'change', () => { + toggleDocClassAndSave( featureName, value, config ); + } ); +} + +/** + * @param {Element} form + * @param {string} featureName + * @param {HTMLElement} labelElement + * @param {string} currentValue + * @param {Record<string,ClientPreference>} config + */ +function appendToggleSwitch( form, featureName, labelElement, currentValue, config ) { + const input = makeInputElement( 'checkbox', featureName, currentValue ); + // input.classList.add( 'cdx-toggle-switch__input' ); + input.classList.add( 'citizen-client-prefs-toggle-switch__input' ); + const switcher = document.createElement( 'span' ); + // switcher.classList.add( 'cdx-toggle-switch__switch' ); + switcher.classList.add( 'citizen-client-prefs-toggle-switch__switch' ); + const grip = document.createElement( 'span' ); + // grip.classList.add( 'cdx-toggle-switch__switch__grip' ); + grip.classList.add( 'citizen-client-prefs-toggle-switch__switch__grip' ); + switcher.appendChild( grip ); + const label = labelElement || makeLabelElement( featureName, currentValue ); + // label.classList.add( 'cdx-toggle-switch__label' ); + label.classList.add( 'citizen-client-prefs-toggle-switch__label' ); + const toggleSwitch = document.createElement( 'span' ); + // toggleSwitch.classList.add( 'cdx-toggle-switch' ); + toggleSwitch.classList.add( 'citizen-client-prefs-toggle-switch' ); + toggleSwitch.appendChild( input ); + toggleSwitch.appendChild( switcher ); + toggleSwitch.appendChild( label ); + input.addEventListener( 'change', () => { + toggleDocClassAndSave( featureName, input.checked ? '1' : '0', config ); + } ); + form.appendChild( toggleSwitch ); +} + +/** + * @param {string} className + * @return {Element} + */ +function createRow( className ) { + const row = document.createElement( 'div' ); + row.setAttribute( 'class', className ); + return row; +} + +/** + * Get the label for the feature. + * + * @param {string} featureName + * @return {MwMessage} + */ +const getFeatureLabelMsg = ( featureName ) => + // eslint-disable-next-line mediawiki/msg-doc + mw.message( `${ featureName }-name` ); + +/** + * adds a toggle button + * + * @param {string} featureName + * @param {Record<string,ClientPreference>} config + * @return {Element|null} + */ +function makeControl( featureName, config ) { + const pref = config[ featureName ]; + if ( !pref ) { + return null; + } + const currentValue = clientPrefs.get( featureName ); + // The client preference was invalid. This shouldn't happen unless a gadget + // or script has modified the documentElement. + if ( typeof currentValue === 'boolean' ) { + return null; + } + const row = createRow( '' ); + const form = document.createElement( 'form' ); + const type = pref.type || 'radio'; + switch ( type ) { + case 'radio': + pref.options.forEach( ( value ) => { + appendRadioToggle( form, featureName, value, currentValue, config ); + } ); + break; + case 'switch': { + const labelElement = document.createElement( 'label' ); + labelElement.textContent = getFeatureLabelMsg( featureName ).text(); + appendToggleSwitch( form, featureName, labelElement, currentValue, config ); + break; + } default: + throw new Error( 'Unknown client preference! Only switch or radio are supported.' ); + } + row.appendChild( form ); + + if ( isFeatureExcluded( featureName ) ) { + const exclusionNotice = makeExclusionNotice( featureName ); + row.appendChild( exclusionNotice ); + } + return row; +} + +/** + * @param {Element} parent + * @param {string} featureName + * @param {Record<string,ClientPreference>} config + */ +function makeClientPreference( parent, featureName, config ) { + const labelMsg = getFeatureLabelMsg( featureName ); + // If the user is not debugging messages and no language exists, + // exit as its a hidden client preference. + if ( !labelMsg.exists() && mw.config.get( 'wgUserLanguage' ) !== 'qqx' ) { + return; + } else { + const id = `skin-client-prefs-${ featureName }`; + const portlet = addPortlet( id, labelMsg.text() ); + const labelElement = portlet.querySelector( 'label' ); + // eslint-disable-next-line mediawiki/msg-doc + const descriptionMsg = mw.message( `${ featureName }-description` ); + if ( descriptionMsg.exists() ) { + const desc = document.createElement( 'span' ); + desc.classList.add( 'skin-client-pref-description' ); + desc.textContent = descriptionMsg.text(); + if ( labelElement && labelElement.parentNode ) { + labelElement.appendChild( desc ); + } + } + const row = makeControl( featureName, config ); + parent.appendChild( portlet ); + if ( row ) { + const tmp = mw.util.addPortletLink( id, '', '' ); + // create a dummy link + if ( tmp ) { + const link = tmp.querySelector( 'a' ); + if ( link ) { + link.replaceWith( row ); + } + } + } + } +} + +/** + * Fills the client side preference dropdown with controls. + * + * @param {string} selector of element to fill with client preferences + * @param {Record<string,ClientPreference>} config + * @return {Promise<Node>} + */ +function render( selector, config ) { + const node = document.querySelector( selector ); + if ( !node ) { + return Promise.reject(); + } + return new Promise( ( resolve ) => { + getVisibleClientPreferences( config ).forEach( ( pref ) => { + makeClientPreference( node, pref, config ); + } ); + mw.requestIdleCallback( () => { + resolve( node ); + } ); + } ); +} + +/** + * @param {string} clickSelector what to click + * @param {string} renderSelector where to render + * @param {Record<string,ClientPreference>} config + */ +function bind( clickSelector, renderSelector, config ) { + let enhanced = false; + const chk = /** @type {HTMLInputElement} */ ( + document.querySelector( clickSelector ) + ); + if ( !chk ) { + return; + } + if ( chk.checked ) { + render( renderSelector, config ); + enhanced = true; + } else { + chk.addEventListener( 'input', () => { + if ( enhanced ) { + return; + } + render( renderSelector, config ); + enhanced = true; + } ); + } +} +module.exports = { + bind, + toggleDocClassAndSave, + render +};
resources/skins.citizen.preferences/clientPreferences.json+6 −0 added@@ -0,0 +1,6 @@ +{ + "skin-theme": { + "options": [ "os", "day", "night" ], + "preferenceKey": "citizen-theme" + } +}
resources/skins.citizen.preferences/clientPrefs.polyfill.js+2 −1 renamed@@ -1,5 +1,6 @@ /** - * mw.user.clientPrefs modified to only use localStorage + * Polyfill for mw.user.clientPrefs for < MW 1.42 + * Modified to use localStorage for all users * TODO: Revisit when we move to MW 1.43 and the interface is more stable */
resources/skins.citizen.preferences/skins.citizen.preferences.js+26 −27 modified@@ -18,7 +18,7 @@ const CLIENTPREFS_THEME_MAP = { dark: 'night' }; -const clientPrefs = require( './clientPrefs.localStorage.js' )(); +const clientPrefs = require( './clientPrefs.polyfill.js' )(); /** * Set the value of the input element @@ -56,7 +56,6 @@ function setIndicator( key, value ) { */ function convertForForm( pref ) { return { - theme: pref.theme, fontsize: Number( pref.fontsize.slice( 0, -1 ) ) / 10 - 8, pagewidth: Number( pref.pagewidth.slice( 0, -2 ) ) / 120 - 6, lineheight: ( pref.lineheight - 1 ) * 10 @@ -105,7 +104,6 @@ function getPref() { }; const pref = { - theme: mw.storage.get( PREFIX_KEY + 'theme' ), fontsize: mw.storage.get( PREFIX_KEY + 'fontsize' ) || initFontSize(), pagewidth: mw.storage.get( PREFIX_KEY + 'pagewidth' ) || rootStyle.getPropertyValue( '--width-layout' ), lineheight: mw.storage.get( PREFIX_KEY + 'lineheight' ) || rootStyle.getPropertyValue( '--line-height' ) @@ -125,17 +123,12 @@ function setPref() { formData = Object.fromEntries( new FormData( document.getElementById( CLASS + '-form' ) ) ), currentPref = convertForForm( getPref() ), newPref = { - theme: formData[ CLASS + '-theme' ], fontsize: Number( formData[ CLASS + '-fontsize' ] ), pagewidth: Number( formData[ CLASS + '-pagewidth' ] ), lineheight: Number( formData[ CLASS + '-lineheight' ] ) }; - if ( currentPref.theme !== newPref.theme ) { - mw.storage.set( PREFIX_KEY + 'theme', newPref.theme ); - clientPrefs.set( 'skin-theme', CLIENTPREFS_THEME_MAP[ newPref.theme ] ); - - } else if ( currentPref.fontsize !== newPref.fontsize ) { + if ( currentPref.fontsize !== newPref.fontsize ) { const formattedFontSize = ( newPref.fontsize + 8 ) * 10 + '%'; mw.storage.set( PREFIX_KEY + 'fontsize', formattedFontSize ); setIndicator( 'fontsize', formattedFontSize ); @@ -166,7 +159,6 @@ function setPref() { * @return {void} */ function resetPref() { - // Do not reset theme as its default value is defined somewhere else const keys = [ 'fontsize', 'pagewidth', 'lineheight' ]; // Remove style @@ -239,23 +231,19 @@ function togglePanel() { toggle = document.getElementById( CLASS + '-toggle' ), panel = document.getElementById( CLASS + '-panel' ), form = document.getElementById( CLASS + '-form' ), - themeOption = document.getElementById( CLASS + '-theme' ), resetButton = document.getElementById( CLASS + '-resetbutton' ); if ( !panel.classList.contains( CLASS_PANEL_ACTIVE ) ) { panel.classList.add( CLASS_PANEL_ACTIVE ); toggle.setAttribute( 'aria-expanded', true ); form.addEventListener( 'input', setPref ); - // Some browser doesn't fire input events when checking radio buttons - themeOption.addEventListener( 'click', setPref ); resetButton.addEventListener( 'click', resetPref ); window.addEventListener( 'click', dismissOnClickOutside ); window.addEventListener( 'keydown', dismissOnEscape ); } else { panel.classList.remove( CLASS_PANEL_ACTIVE ); toggle.setAttribute( 'aria-expanded', false ); form.removeEventListener( 'input', setPref ); - themeOption.removeEventListener( 'click', setPref ); resetButton.removeEventListener( 'click', resetPref ); window.removeEventListener( 'click', dismissOnClickOutside ); window.removeEventListener( 'keydown', dismissOnEscape ); @@ -270,10 +258,6 @@ function togglePanel() { function getMessages() { const keys = [ 'preferences', - 'prefs-citizen-theme-label', - 'prefs-citizen-theme-option-auto', - 'prefs-citizen-theme-option-light', - 'prefs-citizen-theme-option-dark', 'prefs-citizen-fontsize-label', 'prefs-citizen-pagewidth-label', 'prefs-citizen-lineheight-label', @@ -313,19 +297,10 @@ function initPanel( event ) { // TODO: Use ES6 template literals when RL does not screw up multiline const panel = template.render( data ).get()[ 1 ]; - // The priorities is as follow: - // 1. User-set theme (localStorage) - // 2. Site default theme (wgCitizenThemeDefault) - // 3. Fallback to auto - const currentTheme = prefValue.theme || - require( './config.json' ).wgCitizenThemeDefault || - 'auto'; - // Attach panel after button event.currentTarget.parentNode.insertBefore( panel, event.currentTarget.nextSibling ); // Set up initial state - document.getElementById( CLASS + '-theme__input__' + currentTheme ).checked = true; keys.forEach( ( key ) => { setIndicator( key, pref[ key ] ); setInputValue( key, prefValue[ key ] ); @@ -334,6 +309,30 @@ function initPanel( event ) { togglePanel(); event.currentTarget.addEventListener( 'click', togglePanel ); event.currentTarget.removeEventListener( 'click', initPanel ); + + const clientPreferenceSelector = '#citizen-client-prefs'; + const clientPreferenceExists = document.querySelectorAll( clientPreferenceSelector ).length > 0; + if ( clientPreferenceExists ) { + const clientPreferences = require( /** @type {string} */ ( './clientPreferences.js' ) ); + const clientPreferenceConfig = ( require( './clientPreferences.json' ) ); + + // Support legacy skin-citizen-* class + // TODO: Remove it in the future version after sufficient time + clientPreferenceConfig[ 'skin-theme' ].callback = () => { + const LEGACY_THEME_CLASSES = [ + 'skin-citizen-auto', + 'skin-citizen-light', + 'skin-citizen-dark' + ]; + const legacyThemeKey = Object.keys( CLIENTPREFS_THEME_MAP ).find( ( key ) => { + return CLIENTPREFS_THEME_MAP[ key ] === clientPrefs.get( 'skin-theme' ); + } ); + document.documentElement.classList.remove( ...LEGACY_THEME_CLASSES ); + document.documentElement.classList.add( `skin-citizen-${ legacyThemeKey }` ); + }; + + clientPreferences.render( clientPreferenceSelector, clientPreferenceConfig ); + } } /**
resources/skins.citizen.preferences/skins.citizen.preferences.less+66 −55 modified@@ -17,31 +17,15 @@ width: 100%; } - &__value { - font-weight: var( --font-weight-medium ); - color: var( --color-base--emphasized ); + &__title { + font-size: var( --font-size-x-small ); + color: var( --color-base--subtle ); + letter-spacing: 0.05em; } - } - &-theme { - &-option { - flex-grow: 1; - padding: 0.5rem 1rem; + &__value { font-weight: var( --font-weight-medium ); - text-align: center; - white-space: nowrap; - cursor: pointer; - border: 2px solid var( --border-color-base ); - border-radius: var( --border-radius--medium ); - - &:hover { - border-color: var( --color-primary--hover ); - box-shadow: var( --box-shadow-card ); - } - - &:active { - border-color: var( --color-primary--active ); - } + color: var( --color-base--emphasized ); } } @@ -89,39 +73,6 @@ margin: var( --space-md ) 0; } - &-theme { - &-option { - &-light { - color: ~'hsl( var( --color-primary__h ), 30%, 20% )'; - background: ~'hsl( var( --color-primary__h ), 25%, 94% )'; - } - - &-dark { - color: ~'hsl( var( --color-primary__h ), 10%, 75% )'; - background: ~'hsl( var( --color-primary__h ), 20%, 10% )'; - } - } - - fieldset { - display: flex; - gap: 0.5rem; - width: 100%; - padding: 0; - margin-top: 0.25rem; - } - - // Let label be the radio button - input { - display: none; - - &:checked { - + label { - border-color: var( --color-primary ); - } - } - } - } - &-resetbutton { width: 100%; padding: var( --space-sm ) var( --space-md ); @@ -145,6 +96,66 @@ } } +// New clientPrefs styles +.citizen-client-prefs { + &-radio { + &__input { + // Hide radio button because we use label as button + display: none; + + &:checked { + ~ .citizen-client-prefs-radio__label { + border-color: var( --color-primary ); + } + } + } + + &__label { + display: block; + padding: var( --space-xs ) var( --space-md ); + font-weight: var( --font-weight-medium ); + border: 2px solid var( --border-color-base ); + border-radius: var( --border-radius--medium ); + } + } +} + +#citizen-client-prefs { + .citizen-menu { + &__content { + padding: 0 var( --space-md ); + } + } +} + +#skin-client-prefs-skin-theme { + form { + display: grid; + // This is not the best because it does not adapt to the text length but will revisit later + grid-template-columns: 1fr 1fr; + gap: var( --space-xxs ); + text-align: center; + } + + label { + background: var( --color-surface-0 ); + + &[ for='skin-client-pref-skin-theme-value-day' ] { + // color-base of day theme + color: ~'hsl( var( --color-primary__h ), 30%, 20% )'; + // color-surface-0 of day theme + background: ~'hsl( var( --color-primary__h ), 30%, 96% )'; + } + + &[ for='skin-client-pref-skin-theme-value-night' ] { + // color-base of night theme + color: ~'hsl( var( --color-primary__h ), 25%, 80% )'; + // color-surface-0 of night theme + background: ~'hsl( var( --color-primary__h ), 20%, 10% )'; + } + } +} + @media ( hover: hover ) { .citizen-pref:hover .citizen-pref__button .citizen-ui-icon::before { transform: rotate3d( 0, 0, 1, 90deg );
resources/skins.citizen.preferences/templates/preferences.mustache+1 −14 modified@@ -11,21 +11,8 @@ <aside id="citizen-pref-panel" class="citizen-pref-panel"> <header id="citizen-pref-header">{{msg-preferences}}</header> + <div id="citizen-client-prefs"></div> <form id="citizen-pref-form"> - {{! Theme }} - <fieldset class="citizen-pref-item" id ="citizen-pref-theme"> - <label for="citizen-pref-theme" class="citizen-pref-item__label"> - <span class="citizen-pref-item__title">{{msg-prefs-citizen-theme-label}}</span> - </label> - <fieldset> - <input id="citizen-pref-theme__input__auto" name="citizen-pref-theme" type="radio" value="auto"> - <label for="citizen-pref-theme__input__auto" id="citizen-pref-theme-option-auto" class="citizen-pref-theme-option">{{msg-prefs-citizen-theme-option-auto}}</label> - <input id="citizen-pref-theme__input__light" name="citizen-pref-theme" type="radio" value="light"> - <label for="citizen-pref-theme__input__light" id="citizen-pref-theme-option-light" class="citizen-pref-theme-option">{{msg-prefs-citizen-theme-option-light}}</label> - <input id="citizen-pref-theme__input__dark" name="citizen-pref-theme" type="radio" value="dark"> - <label for="citizen-pref-theme__input__dark" id="citizen-pref-theme-option-dark" class="citizen-pref-theme-option">{{msg-prefs-citizen-theme-option-dark}}</label> - </fieldset> - </fieldset> {{! Font size }} <fieldset class="citizen-pref-item" id="citizen-pref-fontsize"> <label for="citizen-pref-fontsize" class="citizen-pref-item__label">
resources/skins.citizen.scripts/inline.js+42 −52 modified@@ -3,32 +3,20 @@ * * Inline script used in includes/Hooks/SkinHooks.php */ -window.applyPref = () => { - const - prefix = 'skin-citizen-', - themeKey = prefix + 'theme'; +const LEGACY_PREFIX = 'skin-citizen-'; +window.applyPref = () => { const getStorage = ( key ) => { return window.localStorage.getItem( key ); }; - // Default to auto if no key is present - const targetTheme = getStorage( themeKey ); - const apply = () => { const cssProps = { fontsize: 'font-size', pagewidth: '--width-layout', lineheight: '--line-height' }; - // Generates an array of prefix-(auto|dark|light) strings - const classNames = () => { - return [ 'auto', 'dark', 'light' ].map( ( themeType ) => { - return prefix + themeType; - } ); - }; - const injectStyles = ( css ) => { const styleId = 'citizen-style'; let style = document.getElementById( styleId ); @@ -42,27 +30,10 @@ window.applyPref = () => { }; try { - const theme = getStorage( themeKey ); - let cssDeclaration = ''; - - // Apply pref by changing class - if ( theme !== null ) { - const htmlElement = document.documentElement; - // Remove all theme classes then add the right one - // The following classes are used here: - // * skin-citizen-auto - // * skin-citizen-light - // * skin-citizen-dark - htmlElement.classList.remove( ...classNames( prefix ) ); - - htmlElement.classList.add( prefix + theme ); - } - // Apply pref by adding CSS to root - for ( const [ key, property ] of Object.entries( cssProps ) ) { - const value = getStorage( prefix + key ); + const value = getStorage( LEGACY_PREFIX + key ); if ( value !== null ) { cssDeclaration += `${ property }:${ value };`; @@ -76,31 +47,50 @@ window.applyPref = () => { } }; - // Set up auto theme based on prefers-color-scheme - if ( targetTheme === 'auto' ) { - const prefersDark = window.matchMedia( '(prefers-color-scheme: dark)' ); - const autoTheme = prefersDark.matches ? 'dark' : 'light'; - - const setStorage = ( key, value ) => { - return window.localStorage.setItem( key, value ); - }; - - // Set to the right theme temporarily - setStorage( themeKey, autoTheme ); - apply(); + apply(); +}; - // Attach listener for future changes - prefersDark.addEventListener( 'change', () => { - apply(); +/** + * Backported from MW 1.42 + * Modified to use localStorage only + */ +window.clientPrefs = () => { + let className = document.documentElement.className; + const storage = localStorage.getItem( 'mwclientpreferences' ); + if ( storage ) { + // TODO: Just use array for localStorage + storage.split( '%2C' ).forEach( function ( pref ) { + className = className.replace( + // eslint-disable-next-line security/detect-non-literal-regexp + new RegExp( '(^| )' + pref.replace( /-clientpref-\w+$|[^\w-]+/g, '' ) + '-clientpref-\\w+( |$)' ), + '$1' + pref + '$2' + ); + + // Legacy support + if ( pref.startsWith( 'skin-theme-clientpref-' ) ) { + const CLIENTPREFS_THEME_MAP = { + os: 'auto', + day: 'light', + night: 'dark' + }; + const matchedKey = CLIENTPREFS_THEME_MAP[ pref.replace( 'skin-theme-clientpref-', '' ) ]; + if ( matchedKey ) { + // eslint-disable-next-line max-len, es-x/no-object-values + const classesToRemove = Object.values( CLIENTPREFS_THEME_MAP ).map( ( theme ) => LEGACY_PREFIX + theme ); + className = className.replace( + // eslint-disable-next-line security/detect-non-literal-regexp + new RegExp( classesToRemove.join( '|' ), 'g' ), + '' + ); + className += ` ${ LEGACY_PREFIX }${ matchedKey }`; + } + } } ); - - // Reset back to auto - setStorage( themeKey, 'auto' ); - } else { - apply(); + document.documentElement.className = className; } }; ( () => { window.applyPref(); + window.clientPrefs(); } )();
skin.json+11 −2 modified@@ -226,7 +226,10 @@ "name": "resources/skins.citizen.preferences/config.json", "callback": "MediaWiki\\Skins\\Citizen\\Hooks\\ResourceLoaderHooks::getCitizenPreferencesResourceLoaderConfig" }, - "resources/skins.citizen.preferences/clientPrefs.localStorage.js" + "resources/skins.citizen.preferences/addPortlet.polyfill.js", + "resources/skins.citizen.preferences/clientPreferences.js", + "resources/skins.citizen.preferences/clientPreferences.json", + "resources/skins.citizen.preferences/clientPrefs.polyfill.js" ], "messages": [ "preferences", @@ -237,7 +240,13 @@ "prefs-citizen-fontsize-label", "prefs-citizen-pagewidth-label", "prefs-citizen-lineheight-label", - "prefs-citizen-resetbutton-label" + "prefs-citizen-resetbutton-label", + "skin-theme-name", + "skin-theme-description", + "skin-theme-day-label", + "skin-theme-night-label", + "skin-theme-os-label", + "skin-theme-exclusion-notice" ], "dependencies": [ "mediawiki.storage",
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-jwr7-992g-68mhghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-49577ghsaADVISORY
- github.com/StarCitizenTools/mediawiki-skins-Citizen/commit/93c36ac778397e0e7c46cf7adb1e5d848265f1bdghsax_refsource_MISCWEB
- github.com/StarCitizenTools/mediawiki-skins-Citizen/commit/a741639085d70c22a9f49890542a142a223bf981ghsax_refsource_MISCWEB
- github.com/StarCitizenTools/mediawiki-skins-Citizen/security/advisories/GHSA-jwr7-992g-68mhghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.