Skip to content

Commit 94f6b03

Browse files
committed
fix: escape HTML entities in localization arguments
cherry-picked from V15
1 parent 586bde9 commit 94f6b03

14 files changed

+109
-22
lines changed

src/libs/localization-api/localization.controller.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ describe('UmbLocalizeController', () => {
175175
expect((controller.term as any)('logout', 'Hello', 'World')).to.equal('Log out');
176176
});
177177

178+
it('should encode HTML entities', () => {
179+
expect(controller.term('withInlineToken', 'Hello', '<script>alert("XSS")</script>'), 'XSS detected').to.equal(
180+
'Hello &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;',
181+
);
182+
});
183+
178184
it('only reacts to changes of its own localization-keys', async () => {
179185
const element: UmbLocalizationRenderCountElement = await fixture(
180186
html`<umb-localization-render-count></umb-localization-render-count>`,
@@ -290,6 +296,12 @@ describe('UmbLocalizeController', () => {
290296
const str = '#missing_translation_key';
291297
expect(controller.string(str)).to.equal('#missing_translation_key');
292298
});
299+
300+
it('should return an empty string if the input is not a string', async () => {
301+
expect(controller.string(123)).to.equal('');
302+
expect(controller.string({})).to.equal('');
303+
expect(controller.string(undefined)).to.equal('');
304+
});
293305
});
294306

295307
describe('host element', () => {

src/libs/localization-api/localization.controller.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
import { umbLocalizationManager } from './localization.manager.js';
2121
import type { LitElement } from '@umbraco-cms/backoffice/external/lit';
2222
import type { UmbController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
23+
import { escapeHTML } from '@umbraco-cms/backoffice/utils';
2324

2425
const LocalizationControllerAlias = Symbol();
2526
/**
@@ -109,38 +110,45 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
109110

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

120122
const { primary, secondary } = this.getLocalizationData(this.lang());
123+
124+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
121125
let term: any;
122126

123127
// Look for a matching term using regionCode, code, then the fallback
124-
if (primary && primary[key]) {
128+
if (primary?.[key]) {
125129
term = primary[key];
126-
} else if (secondary && secondary[key]) {
130+
} else if (secondary?.[key]) {
127131
term = secondary[key];
128-
} else if (umbLocalizationManager.fallback && umbLocalizationManager.fallback[key]) {
132+
} else if (umbLocalizationManager.fallback?.[key]) {
129133
term = umbLocalizationManager.fallback[key];
130134
} else {
131135
return String(key);
132136
}
133137

138+
// As translated texts can contain HTML, we will need to render with unsafeHTML.
139+
// But arguments can come from user input, so they should be escaped.
140+
const sanitizedArgs = args.map((a) => escapeHTML(a));
141+
134142
if (typeof term === 'function') {
135-
return term(...args) as string;
143+
return term(...sanitizedArgs) as string;
136144
}
137145

138146
if (typeof term === 'string') {
139-
if (args.length > 0) {
147+
if (sanitizedArgs.length) {
140148
// Replace placeholders of format "%index%" and "{index}" with provided values
141149
term = term.replace(/(%(\d+)%|\{(\d+)\})/g, (match, _p1, p2, p3): string => {
142150
const index = p2 || p3;
143-
return String(args[index] || match);
151+
return typeof sanitizedArgs[index] !== 'undefined' ? String(sanitizedArgs[index]) : match;
144152
});
145153
}
146154
}
@@ -178,7 +186,18 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
178186
return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
179187
}
180188

181-
string(text: string): string {
189+
/**
190+
* Translates a string containing one or more terms. The terms should be prefixed with a `#` character.
191+
* If the term is found in the localization set, it will be replaced with the localized term.
192+
* If the term is not found, the original term will be returned.
193+
* @param {string} text The text to translate.
194+
* @returns {string} The translated text.
195+
*/
196+
string(text: unknown): string {
197+
if (typeof text !== 'string') {
198+
return '';
199+
}
200+
182201
// find all words starting with #
183202
const regex = /#\w+/g;
184203

src/packages/core/auth/auth-flow.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* License for the specific language governing permissions and limitations under
1414
* the License.
1515
*/
16-
import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js';
16+
import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './constants.js';
1717
import type { LocationLike, StringMap } from '@umbraco-cms/backoffice/external/openid';
1818
import {
1919
BaseTokenRequestHandler,

src/packages/core/auth/auth.context.token.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,3 @@ import type { UmbAuthContext } from './auth.context.js';
22
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
33

44
export const UMB_AUTH_CONTEXT = new UmbContextToken<UmbAuthContext>('UmbAuthContext');
5-
export const UMB_STORAGE_TOKEN_RESPONSE_NAME = 'umb:userAuthTokenResponse';
6-
export const UMB_STORAGE_REDIRECT_URL = 'umb:auth:redirect';

src/packages/core/auth/auth.context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { UmbBackofficeExtensionRegistry, ManifestAuthProvider } from '../extension-registry/index.js';
22
import { UmbAuthFlow } from './auth-flow.js';
3-
import { UMB_AUTH_CONTEXT, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js';
3+
import { UMB_AUTH_CONTEXT } from './auth.context.token.js';
4+
import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './constants.js';
45
import type { UmbOpenApiConfiguration } from './models/openApiConfiguration.js';
56
import { OpenAPI } from '@umbraco-cms/backoffice/external/backend-api';
67
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const UMB_STORAGE_TOKEN_RESPONSE_NAME = 'umb:userAuthTokenResponse';

src/packages/core/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import './components/index.js';
22

33
export * from './auth.context.js';
44
export * from './auth.context.token.js';
5+
export * from './constants.js';
56
export * from './modals/index.js';
67
export * from './models/openApiConfiguration.js';
78
export * from './components/index.js';

src/packages/core/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './path/stored-path.function.js';
1717
export * from './path/transform-server-path-to-client-path.function.js';
1818
export * from './path/umbraco-path.function.js';
1919
export * from './path/url-pattern-to-string.function.js';
20+
export * from './sanitize/escape-html.function.js';
2021
export * from './sanitize/sanitize-html.function.js';
2122
export * from './selection-manager/selection.manager.js';
2223
export * from './state-manager/index.js';

src/packages/core/utils/path/stored-path.function.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { retrieveStoredPath, setStoredPath } from './stored-path.function.js';
1+
import { retrieveStoredPath, setStoredPath, UMB_STORAGE_REDIRECT_URL } from './stored-path.function.js';
22
import { expect } from '@open-wc/testing';
3-
import { UMB_STORAGE_REDIRECT_URL } from '@umbraco-cms/backoffice/auth';
43

54
describe('retrieveStoredPath', () => {
65
beforeEach(() => {

src/packages/core/utils/path/stored-path.function.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ensureLocalPath } from './ensure-local-path.function.js';
2-
import { UMB_STORAGE_REDIRECT_URL } from '@umbraco-cms/backoffice/auth';
2+
3+
export const UMB_STORAGE_REDIRECT_URL = 'umb:auth:redirect';
34

45
/**
56
* Retrieve the stored path from the session storage.

0 commit comments

Comments
 (0)