Moderate severityNVD Advisory· Published Jan 21, 2025· Updated Feb 12, 2025
Umbraco Backoffice Components Have XSS/HTML Injection Vulnerability
CVE-2025-24012
Description
Umbraco is a free and open source .NET content management system. Starting in version 14.0.0 and prior to versions 14.3.2 and 15.1.2, authenticated users are able to exploit a cross-site scripting vulnerability when viewing certain localized backoffice components. Versions 14.3.2 and 15.1.2 contain a patch.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
Umbraco.Cms.StaticAssetsNuGet | >= 14.0.0, < 14.3.2 | 14.3.2 |
Umbraco.Cms.StaticAssetsNuGet | >= 15.0.0, < 15.1.2 | 15.1.2 |
@umbraco-cms/backofficenpm | >= 14.0.0, < 14.3.2 | 14.3.2 |
@umbraco-cms/backofficenpm | >= 15.0.0, < 15.1.2 | 15.1.2 |
Affected products
1- Range: >= 14.0.0, < 14.3.2
Patches
1d4f8754f9338Merge commit from fork
16 files changed · +96 −23
src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts+6 −0 modified@@ -175,6 +175,12 @@ describe('UmbLocalizeController', () => { expect((controller.term as any)('logout', 'Hello', 'World')).to.equal('Log out'); }); + it('should encode HTML entities', () => { + expect(controller.term('withInlineToken', 'Hello', '<script>alert("XSS")</script>'), 'XSS detected').to.equal( + 'Hello <script>alert("XSS")</script>', + ); + }); + it('only reacts to changes of its own localization-keys', async () => { const element: UmbLocalizationRenderCountElement = await fixture( html`<umb-localization-render-count></umb-localization-render-count>`,
src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts+13 −6 modified@@ -20,6 +20,7 @@ import type { import { umbLocalizationManager } from './localization.manager.js'; import type { LitElement } from '@umbraco-cms/backoffice/external/lit'; import type { UmbController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { escapeHTML } from '@umbraco-cms/backoffice/utils'; const LocalizationControllerAlias = Symbol(); /** @@ -119,29 +120,35 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati } const { primary, secondary } = this.getLocalizationData(this.lang()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any let term: any; // Look for a matching term using regionCode, code, then the fallback - if (primary && primary[key]) { + if (primary?.[key]) { term = primary[key]; - } else if (secondary && secondary[key]) { + } else if (secondary?.[key]) { term = secondary[key]; - } else if (umbLocalizationManager.fallback && umbLocalizationManager.fallback[key]) { + } else if (umbLocalizationManager.fallback?.[key]) { term = umbLocalizationManager.fallback[key]; } else { return String(key); } + // As translated texts can contain HTML, we will need to render with unsafeHTML. + // But arguments can come from user input, so they should be escaped. + const sanitizedArgs = args.map((a) => escapeHTML(a)); + if (typeof term === 'function') { - return term(...args) as string; + return term(...sanitizedArgs) as string; } if (typeof term === 'string') { - if (args.length > 0) { + if (sanitizedArgs.length) { // Replace placeholders of format "%index%" and "{index}" with provided values term = term.replace(/(%(\d+)%|\{(\d+)\})/g, (match, _p1, p2, p3): string => { const index = p2 || p3; - return String(args[index] || match); + return typeof sanitizedArgs[index] !== 'undefined' ? String(sanitizedArgs[index]) : match; }); } }
src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.token.ts+0 −2 modified@@ -2,5 +2,3 @@ import type { UmbAuthContext } from './auth.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; export const UMB_AUTH_CONTEXT = new UmbContextToken<UmbAuthContext>('UmbAuthContext'); -export const UMB_STORAGE_TOKEN_RESPONSE_NAME = 'umb:userAuthTokenResponse'; -export const UMB_STORAGE_REDIRECT_URL = 'umb:auth:redirect';
src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts+2 −1 modified@@ -1,7 +1,8 @@ import { UmbAuthFlow } from './auth-flow.js'; -import { UMB_AUTH_CONTEXT, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js'; +import { UMB_AUTH_CONTEXT } from './auth.context.token.js'; import type { UmbOpenApiConfiguration } from './models/openApiConfiguration.js'; import type { ManifestAuthProvider } from './auth-provider.extension.js'; +import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './constants.js'; import { OpenAPI } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts+1 −1 modified@@ -13,7 +13,7 @@ * License for the specific language governing permissions and limitations under * the License. */ -import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js'; +import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './constants.js'; import type { LocationLike, StringMap } from '@umbraco-cms/backoffice/external/openid'; import { BaseTokenRequestHandler,
src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts+4 −2 modified@@ -1,15 +1,17 @@ import type { UmbAuthProviderDefaultProps, UmbUserLoginState } from '../types.js'; -import { UmbLitElement } from '../../lit-element/lit-element.element.js'; -import { UmbTextStyles } from '../../style/index.js'; import type { ManifestAuthProvider } from '../auth-provider.extension.js'; import { css, customElement, html, nothing, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; @customElement('umb-auth-provider-default') export class UmbAuthProviderDefaultElement extends UmbLitElement implements UmbAuthProviderDefaultProps { @property({ attribute: false }) userLoginState?: UmbUserLoginState | undefined; + @property({ attribute: false }) manifest!: ManifestAuthProvider; + @property({ attribute: false }) onSubmit!: (manifestOrProviderName: string | ManifestAuthProvider, loginHint?: string) => void;
src/Umbraco.Web.UI.Client/src/packages/core/auth/constants.ts+1 −0 added@@ -0,0 +1 @@ +export const UMB_STORAGE_TOKEN_RESPONSE_NAME = 'umb:userAuthTokenResponse';
src/Umbraco.Web.UI.Client/src/packages/core/auth/index.ts+1 −0 modified@@ -2,6 +2,7 @@ import './components/index.js'; export * from './auth.context.js'; export * from './auth.context.token.js'; +export * from './constants.js'; export * from './modals/index.js'; export type * from './models/openApiConfiguration.js'; export * from './components/index.js';
src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts+1 −0 modified@@ -19,6 +19,7 @@ export * from './path/stored-path.function.js'; export * from './path/transform-server-path-to-client-path.function.js'; export * from './path/umbraco-path.function.js'; export * from './path/url-pattern-to-string.function.js'; +export * from './sanitize/escape-html.function.js'; export * from './sanitize/sanitize-html.function.js'; export * from './selection-manager/selection.manager.js'; export * from './state-manager/index.js';
src/Umbraco.Web.UI.Client/src/packages/core/utils/path/stored-path.function.test.ts+1 −2 modified@@ -1,6 +1,5 @@ -import { retrieveStoredPath, setStoredPath } from './stored-path.function.js'; +import { retrieveStoredPath, setStoredPath, UMB_STORAGE_REDIRECT_URL } from './stored-path.function.js'; import { expect } from '@open-wc/testing'; -import { UMB_STORAGE_REDIRECT_URL } from '@umbraco-cms/backoffice/auth'; describe('retrieveStoredPath', () => { beforeEach(() => {
src/Umbraco.Web.UI.Client/src/packages/core/utils/path/stored-path.function.ts+2 −1 modified@@ -1,5 +1,6 @@ import { ensureLocalPath } from './ensure-local-path.function.js'; -import { UMB_STORAGE_REDIRECT_URL } from '@umbraco-cms/backoffice/auth'; + +export const UMB_STORAGE_REDIRECT_URL = 'umb:auth:redirect'; /** * Retrieve the stored path from the session storage.
src/Umbraco.Web.UI.Client/src/packages/core/utils/sanitize/escape-html.function.test.ts+8 −0 added@@ -0,0 +1,8 @@ +import { expect } from '@open-wc/testing'; +import { escapeHTML } from './escape-html.function.js'; + +describe('escapeHtml', () => { + it('should escape html', () => { + expect(escapeHTML('<script>alert("XSS")</script>')).to.equal('<script>alert("XSS")</script>'); + }); +});
src/Umbraco.Web.UI.Client/src/packages/core/utils/sanitize/escape-html.function.ts+29 −0 added@@ -0,0 +1,29 @@ +const SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; +// Match everything outside of normal chars and " (quote character) +const NON_ALPHANUMERIC_REGEXP = /([^#-~| |!])/g; + +/** + * Escapes HTML entities in a string. + * @example escapeHTML('<script>alert("XSS")</script>'), // "<script>alert("XSS")</script>" + * @param html The HTML string to escape. + * @returns The sanitized HTML string. + */ +export function escapeHTML(html: unknown): string { + if (typeof html !== 'string' && html instanceof String === false) { + return html as string; + } + + return html + .toString() + .replace(/&/g, '&') + .replace(SURROGATE_PAIR_REGEXP, function (value) { + const hi = value.charCodeAt(0); + const low = value.charCodeAt(1); + return '&#' + ((hi - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000) + ';'; + }) + .replace(NON_ALPHANUMERIC_REGEXP, function (value) { + return '&#' + value.charCodeAt(0) + ';'; + }) + .replace(/</g, '<') + .replace(/>/g, '>'); +}
src/Umbraco.Web.UI.Client/src/packages/core/utils/sanitize/sanitize-html.function.test.ts+24 −0 added@@ -0,0 +1,24 @@ +import { expect } from '@open-wc/testing'; +import { sanitizeHTML } from './sanitize-html.function.js'; + +describe('sanitizeHTML', () => { + it('should allow benign HTML', () => { + expect(sanitizeHTML('<strong>Test</strong>')).to.equal('<strong>Test</strong>'); + }); + + it('should remove potentially harmful content', () => { + expect(sanitizeHTML('<script>alert("XSS")</script>')).to.equal(''); + }); + + it('should remove potentially harmful attributes', () => { + expect(sanitizeHTML('<a href="javascript:alert(\'XSS\')">Test</a>')).to.equal('<a>Test</a>'); + }); + + it('should remove potentially harmful content and attributes', () => { + expect(sanitizeHTML('<a href="javascript:alert(\'XSS\')"><script>alert("XSS")</script></a>')).to.equal('<a></a>'); + }); + + it('should allow benign attributes', () => { + expect(sanitizeHTML('<a href="/test">Test</a>')).to.equal('<a href="/test">Test</a>'); + }); +});
src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts+1 −6 modified@@ -6,7 +6,6 @@ import { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbLanguageCollectionRepository, type UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language'; import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; -import { sanitizeHTML } from '@umbraco-cms/backoffice/utils'; @customElement('umb-workspace-view-dictionary-editor') export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement { @@ -22,10 +21,6 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement { @state() private _currentUserHasAccessToAllLanguages?: boolean = false; - get #dictionaryName() { - return typeof this._dictionary?.name !== 'undefined' ? sanitizeHTML(this._dictionary.name) : '...'; - } - readonly #languageCollectionRepository = new UmbLanguageCollectionRepository(this); #workspaceContext?: typeof UMB_DICTIONARY_WORKSPACE_CONTEXT.TYPE; #currentUserContext?: typeof UMB_CURRENT_USER_CONTEXT.TYPE; @@ -89,7 +84,7 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement { override render() { return html` <uui-box> - ${this.localize.term('dictionaryItem_description', this.#dictionaryName)} + <umb-localize key="dictionaryItem_description" .args=${[this._dictionary?.name ?? '...']}></umb-localize> ${repeat( this._languages, (item) => item.unique,
src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts+2 −2 modified@@ -52,7 +52,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/auth', - consts: ["UMB_AUTH_CONTEXT","UMB_STORAGE_TOKEN_RESPONSE_NAME","UMB_STORAGE_REDIRECT_URL","UMB_MODAL_APP_AUTH"] + consts: ["UMB_AUTH_CONTEXT","UMB_STORAGE_TOKEN_RESPONSE_NAME","UMB_MODAL_APP_AUTH"] }, { path: '@umbraco-cms/backoffice/block-custom-view', @@ -400,7 +400,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/utils', - consts: [] + consts: ["UMB_STORAGE_REDIRECT_URL"] }, { path: '@umbraco-cms/backoffice/validation',
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-wv8v-rmw2-25wcghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-24012ghsaADVISORY
- github.com/umbraco/Umbraco-CMS/commit/d4f8754f933895b3a329296e25ddea6f84a0aea2ghsax_refsource_MISCWEB
- github.com/umbraco/Umbraco-CMS/security/advisories/GHSA-wv8v-rmw2-25wcghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.