Skip to content

Commit

Permalink
fix: escape HTML entities in localization arguments
Browse files Browse the repository at this point in the history
cherry-picked from V15
  • Loading branch information
iOvergaard committed Jan 20, 2025
1 parent 586bde9 commit 94f6b03
Show file tree
Hide file tree
Showing 14 changed files with 109 additions and 22 deletions.
12 changes: 12 additions & 0 deletions src/libs/localization-api/localization.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;',
);
});

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>`,
Expand Down Expand Up @@ -290,6 +296,12 @@ describe('UmbLocalizeController', () => {
const str = '#missing_translation_key';
expect(controller.string(str)).to.equal('#missing_translation_key');
});

it('should return an empty string if the input is not a string', async () => {
expect(controller.string(123)).to.equal('');
expect(controller.string({})).to.equal('');
expect(controller.string(undefined)).to.equal('');
});
});

describe('host element', () => {
Expand Down
37 changes: 28 additions & 9 deletions src/libs/localization-api/localization.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
/**
Expand Down Expand Up @@ -109,38 +110,45 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati

/**
* Outputs a translated term.
* @param key
* @param {...any} args
* @param {string} key - the localization key, the indicator of what localization entry you want to retrieve.
* @param {...any} args - the arguments to parse for this localization entry.
* @returns {string} - the translated term as a string.
*/
term<K extends keyof LocalizationSetType>(key: K, ...args: FunctionParams<LocalizationSetType[K]>): string {
if (!this.#usedKeys.includes(key)) {
this.#usedKeys.push(key);
}

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;
});
}
}
Expand Down Expand Up @@ -178,7 +186,18 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
}

string(text: string): string {
/**
* Translates a string containing one or more terms. The terms should be prefixed with a `#` character.
* If the term is found in the localization set, it will be replaced with the localized term.
* If the term is not found, the original term will be returned.
* @param {string} text The text to translate.
* @returns {string} The translated text.
*/
string(text: unknown): string {
if (typeof text !== 'string') {
return '';
}

// find all words starting with #
const regex = /#\w+/g;

Expand Down
2 changes: 1 addition & 1 deletion src/packages/core/auth/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions src/packages/core/auth/auth.context.token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 2 additions & 1 deletion src/packages/core/auth/auth.context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { UmbBackofficeExtensionRegistry, ManifestAuthProvider } from '../extension-registry/index.js';
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 { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './constants.js';
import type { UmbOpenApiConfiguration } from './models/openApiConfiguration.js';
import { OpenAPI } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
Expand Down
1 change: 1 addition & 0 deletions src/packages/core/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const UMB_STORAGE_TOKEN_RESPONSE_NAME = 'umb:userAuthTokenResponse';
1 change: 1 addition & 0 deletions src/packages/core/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 * from './models/openApiConfiguration.js';
export * from './components/index.js';
Expand Down
1 change: 1 addition & 0 deletions src/packages/core/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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';
Expand Down
3 changes: 1 addition & 2 deletions src/packages/core/utils/path/stored-path.function.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down
3 changes: 2 additions & 1 deletion src/packages/core/utils/path/stored-path.function.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
16 changes: 16 additions & 0 deletions src/packages/core/utils/sanitize/escape-html.function.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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('&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;');
});

it('should escape html with single quotes', () => {
expect(escapeHTML("<script>alert('XSS')</script>")).to.equal('&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;');
});

it('should escape html with mixed quotes', () => {
expect(escapeHTML("<script>alert('XSS')</script>")).to.equal('&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;');
});
});
19 changes: 19 additions & 0 deletions src/packages/core/utils/sanitize/escape-html.function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Escapes HTML entities in a string.
* @example escapeHTML('<script>alert("XSS")</script>'), // "&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;"
* @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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
24 changes: 24 additions & 0 deletions src/packages/core/utils/sanitize/sanitize-html.function.test.ts
Original file line number Diff line number Diff line change
@@ -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>');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 94f6b03

Please sign in to comment.