Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use strict';

/**
* Flags `unsafeHTML(<x>.localize.string(...))` and `unsafeHTML(<x>.localize.term(...))`.
*
* Wrapping a localized string in `unsafeHTML` leaves any interpolated args un-escaped — an XSS hazard
* when the args are user-controlled. Use `<x>.localize.htmlString(text, ...args)` instead, which
* escapes args via escapeHTML and returns a Lit unsafeHTML directive ready to inline in templates.
*
* See `docs/security.md` (XSS Prevention → Localized HTML) for the full pattern.
*/

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Disallow `unsafeHTML(<x>.localize.string|term(...))`. Use `<x>.localize.htmlString(...)` instead.',
category: 'Possible Errors',
recommended: true,
},
schema: [],
messages: {
unsafeLocalize:
'Avoid `unsafeHTML(...localize.{{method}}(...))` — interpolated args are not escaped (XSS hazard). Use `localize.htmlString(...)` instead.',
},
},
create(context) {
/**
* Returns true if the given AST node represents a `<...>.localize` member access,
* e.g. `this.localize`, `this.#localize`, `host.localize`, `this._localize`.
*/
function isLocalizeMemberAccess(node) {
if (!node || node.type !== 'MemberExpression') return false;
const prop = node.property;
if (!prop) return false;
// Match `localize` (regular) or any private/aliased identifier ending in `localize` (e.g. `#localize`, `_localize`).
if (prop.type === 'Identifier' && /localize$/i.test(prop.name)) return true;
if (prop.type === 'PrivateIdentifier' && /localize$/i.test(prop.name)) return true;
return false;
}

return {
CallExpression(node) {
// Looking for: unsafeHTML(<inner>)
if (!node.callee || node.callee.type !== 'Identifier' || node.callee.name !== 'unsafeHTML') return;
if (node.arguments.length === 0) return;

const arg = node.arguments[0];
if (!arg || arg.type !== 'CallExpression') return;

// <inner> must itself be a member-call: <localizeExpr>.string(...) or .term(...)
const innerCallee = arg.callee;
if (!innerCallee || innerCallee.type !== 'MemberExpression') return;
if (innerCallee.property?.type !== 'Identifier') return;

const method = innerCallee.property.name;
if (method !== 'string' && method !== 'term') return;

if (!isLocalizeMemberAccess(innerCallee.object)) return;

context.report({
node,
messageId: 'unsafeLocalize',
data: { method },
});
},
};
},
};
88 changes: 65 additions & 23 deletions src/Umbraco.Web.UI.Client/docs/security.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Security

[← Umbraco Backoffice](../CLAUDE.md) | [← Monorepo Root](../../CLAUDE.md)

---


### Input Validation

**Validate All User Input**:
Expand Down Expand Up @@ -40,27 +40,10 @@ private _validateName(name: string): boolean {
}
```

**Sanitize HTML**:

```typescript
// Use the sanitizeHTML utility, which wraps DOMPurify internally
import { sanitizeHTML } from '@umbraco-cms/backoffice/utils';

const cleanHtml = sanitizeHTML(userInput);

// In Lit templates, use unsafeHTML directive with sanitized content
import { unsafeHTML } from '@umbraco-cms/backoffice/external/lit';

render() {
return html`<div>${unsafeHTML(sanitizeHTML(this.htmlContent))}</div>`;
}
```

> **Note**: Do not import or call `DOMPurify` directly. Always use `sanitizeHTML` from `@umbraco-cms/backoffice/utils`.

### Authentication & Authorization

**OpenID Connect with PKCE** (v17+):

- Backoffice uses PKCE authorization code flow
- Real tokens are stored exclusively in `__Host-umbAccessToken` / `__Host-umbRefreshToken` httpOnly cookies — JavaScript cannot read them
- The client-side bearer token value is always the literal string `'[redacted]'` — the server (`HideBackOfficeTokensHandler`) swaps it for the real cookie on each request
Expand Down Expand Up @@ -99,6 +82,7 @@ async #handleDelete() {
```

**Context Security**:

- Use Context API for auth state (`UMB_AUTH_CONTEXT`)
- Never store tokens in localStorage, sessionStorage, or JS variables
- Backend handles token refresh via httpOnly cookies
Expand All @@ -123,11 +107,13 @@ const response = await client.getById({ id });
```

**CORS** (Backend Configuration):

- Configured in .NET backend
- Backoffice follows same-origin policy
- API calls to same origin

**Rate Limiting** (Backend):

- Handled by .NET backend
- Backoffice respects rate limit headers

Expand All @@ -136,16 +122,58 @@ const response = await client.getById({ id });
**Template Security** (Lit):

```typescript
import { html, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
import { sanitizeHTML, escapeHTML } from '@umbraco-cms/backoffice/utils';

// Lit automatically escapes content in templates
render() {

// UNSAFE - This is only safe if you are 100% sure that htmlContent is sanitized properly before being set, and that it cannot be manipulated by user input in any way. Use with extreme caution.
return html`<div>${unsafeHTML(this.htmlContent)}</div>`;

// Safe - Automatically escaped
return html`<div>${this.userContent}</div>`;

// UNSAFE - Only use with sanitized content
return html`<div>${unsafeHTML(DOMPurify.sanitize(this.htmlContent))}</div>`;
// Safe - Use with sanitized content
return html`<div>${unsafeHTML(sanitizeHTML(this.htmlContent))}</div>`;

// Safe - Use with escaped content, which is essentially what Lit does by default
return html`<div>${unsafeHTML(escapeHTML(this.htmlContent))}</div>`;

// Safe - localize.htmlString() escapes interpolated args and wraps in unsafeHTML for rendering
return html`<div>${this.localize.htmlString('#someKey_withHtml', this.userContent)}</div>`;

// Safe - <umb-localize> component automatically escapes arguments
return html`<umb-localize key="someKey_withHtml" .args=${[ this.userContent ]}></umb-localize>`;
Comment thread
iOvergaard marked this conversation as resolved.
}
```

**Localized HTML — `localize.string()` vs `localize.htmlString()`**:

- `localize.string(text, ...args)` — returns a plain string. Use for **non-HTML** contexts: attribute bindings (Lit auto-escapes), notification messages, button labels, log strings. Args are NOT escaped because Lit (or the consumer) handles the appropriate escaping for the context.
- `localize.htmlString(text, ...args)` — returns a Lit directive that renders via `unsafeHTML` with all args HTML-escaped. Use whenever the localized value contains HTML markup that must be rendered (e.g. `<a>` links, `<strong>` emphasis) — this is the only safe path when interpolating user-controlled args into HTML output.

```typescript
// ✅ Plain text — string() is correct (Lit escapes the attribute itself)
html`<uui-button label=${this.localize.string('#actions_delete')}></uui-button>`;

// ✅ HTML rendering — htmlString() escapes args + wraps in unsafeHTML
html`<p>${this.localize.htmlString('#defaultdialogs_confirmdelete', userControlledName)}</p>`;

// ❌ Manually combining string() + unsafeHTML leaves args un-escaped — XSS hazard
html`<p>${unsafeHTML(this.localize.string('#defaultdialogs_confirmdelete', userControlledName))}</p>`;
```

**Modal `content` field** (e.g. `umbConfirmModal`) renders strings via `unsafeHTML` internally. When passing a localized string with user-controlled args, wrap it in a template:

```typescript
// ✅ Safe — htmlString escapes args, html`...` wraps the directive in a TemplateResult
umbConfirmModal(this, {
headline: '#actions_delete',
content: html`${this.#localize.htmlString('#defaultdialogs_confirmdelete', item.name)}`,
});
```

**Attribute Binding**:

```typescript
Expand All @@ -172,12 +200,14 @@ render() {
### Content Security Policy

**CSP Headers** (Backend Configuration):

- Configured in .NET backend
- Restricts script sources
- Prevents inline scripts (except with nonce)
- Reports violations

**Backoffice Compliance**:

- No inline scripts
- No `eval()` or `Function()` constructor
- Monaco Editor uses web workers (CSP compliant)
Expand All @@ -198,66 +228,78 @@ npm update
```

**Dependency Security Practices**:

- Renovate bot automatically creates PRs for updates
- Review dependency changes before merging
- Only use packages from npm registry
- Verify package integrity
- Keep dependencies updated

**Known Vulnerabilities**:

- CI checks for vulnerabilities on every PR
- Security advisories reviewed regularly

### Common Vulnerabilities

**XSS (Cross-Site Scripting)**:

- ✅ Lit templates automatically escape content
- ✅ DOMPurify for HTML sanitization
- ❌ Never use `unsafeHTML` with user input directly
- ❌ Never set `innerHTML` with user input

**CSRF (Cross-Site Request Forgery)**:

- ✅ Backend sends CSRF tokens
- ✅ OpenAPI client includes tokens automatically
- ✅ SameSite cookies

**Injection Attacks**:

- ✅ Backend uses parameterized queries
- ✅ Input validation on both frontend and backend
- ✅ OpenAPI client prevents injection

**Prototype Pollution**:

- ❌ Never use `Object.assign` with user input as source
- ❌ Never use `_.merge` with untrusted data
- ✅ Validate object shapes before using

**ReDoS (Regular Expression Denial of Service)**:

- ✅ Review complex regex patterns
- ✅ Test regex with long inputs
- ❌ Avoid backtracking in regex

### Secure Coding Practices

**Don't Trust Client Data**:

- Validate on backend (primary defense)
- Frontend validation is UX, not security

**Principle of Least Privilege**:

- Only request permissions needed
- Check permissions before sensitive operations
- Hide UI for unavailable actions

**Sanitize Output**:

- Always sanitize HTML before rendering
- Escape special characters in user content
- Use Lit's automatic escaping

**Secure Defaults**:

- Forms should validate by default
- Sensitive operations require confirmation
- Errors don't expose sensitive information

**Defense in Depth**:

- Multiple layers of security
- Frontend validation + Backend validation
- Input sanitization + Output escaping
Expand All @@ -266,6 +308,7 @@ npm update
### Security Anti-Patterns to Avoid

❌ **Never do this**:

```typescript
// XSS vulnerability
element.innerHTML = userInput;
Expand All @@ -289,6 +332,7 @@ window.location.href = url;
```

✅ **Do this instead**:

```typescript
// Safe HTML rendering
element.textContent = userInput;
Expand All @@ -315,5 +359,3 @@ if (url.protocol === 'https:' || url.protocol === 'http:') {
window.location.href = url.href;
}
```


2 changes: 2 additions & 0 deletions src/Umbraco.Web.UI.Client/eslint-local-rules.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const noDirectApiImportRule = require('./devops/eslint/rules/no-direct-api-impor
const preferImportAliasesRule = require('./devops/eslint/rules/prefer-import-aliases.cjs');
const preferStaticStylesLastRule = require('./devops/eslint/rules/prefer-static-styles-last.cjs');
const noRelativeImportToImportMapModule = require('./devops/eslint/rules/no-relative-import-to-import-map-module.cjs');
const noUnsafeLocalize = require('./devops/eslint/rules/no-unsafe-localize.cjs');
const enforceManifestAliasRule = require('./devops/eslint/rules/enforce-manifest-alias.cjs');

module.exports = {
Expand All @@ -17,5 +18,6 @@ module.exports = {
'prefer-import-aliases': preferImportAliasesRule,
'prefer-static-styles-last': preferStaticStylesLastRule,
'no-relative-import-to-import-map-module': noRelativeImportToImportMapModule,
'no-unsafe-localize': noUnsafeLocalize,
'enforce-manifest-alias': enforceManifestAliasRule,
};
1 change: 1 addition & 0 deletions src/Umbraco.Web.UI.Client/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export default [
'import/no-cycle': ['error', { maxDepth: 6, allowUnsafeDynamicCyclicDependency: true }],
'local-rules/enforce-manifest-alias': 'warn',
'local-rules/prefer-static-styles-last': 'warn',
'local-rules/no-unsafe-localize': 'error',
'local-rules/enforce-umbraco-external-imports': [
'error',
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { UmbInstallerContext } from '../installer.context.js';
import { UMB_INSTALLER_CONTEXT } from '../installer.context.js';
import type { CSSResultGroup } from '@umbraco-cms/backoffice/external/lit';
import { css, html, customElement, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
import { sanitizeHTML } from '@umbraco-cms/backoffice/utils';

import type {
ConsentLevelPresentationModel,
Expand Down Expand Up @@ -73,7 +74,9 @@ export class UmbInstallerConsentElement extends UmbLitElement {

private _renderSlider() {
if (!this._telemetryLevels || this._telemetryLevels.length < 1) return;

const sanitizedDescription = this._selectedTelemetry?.description
? sanitizeHTML(this._selectedTelemetry.description)
: '';
return html`
<uui-slider
${umbFocus()}
Expand All @@ -85,7 +88,7 @@ export class UmbInstallerConsentElement extends UmbLitElement {
min="1"
max=${this._telemetryLevels.length}></uui-slider>
<h2>${this._selectedTelemetry.level}</h2>
<p>${unsafeHTML(this._selectedTelemetry.description)}</p>
<p>${unsafeHTML(sanitizedDescription)}</p>
`;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,23 @@ describe('UmbLocalizationController', () => {
});
});

describe('htmlString', () => {
it('should HTML-escape interpolated arguments', async () => {
const xss = '<script>alert("XSS")</script>';
// Render the directive into an element to inspect the resulting innerHTML
const host = await fixture<HTMLElement>(html`<div>${controller.htmlString('#withInlineToken', xss, '')}</div>`);
expect(host.innerHTML, 'XSS detected').to.not.contain('<script>');
expect(host.innerHTML).to.contain('&lt;script&gt;');
});

it('should HTML-escape the toString() representation of non-string args', async () => {
const xss = { toString: () => '<script>alert("XSS")</script>' };
const host = await fixture<HTMLElement>(html`<div>${controller.htmlString('#withInlineToken', xss, '')}</div>`);
expect(host.innerHTML, 'XSS via toString detected').to.not.contain('<script>');
expect(host.innerHTML).to.contain('&lt;script&gt;');
});
});

describe('host element', () => {
let element: UmbLocalizeControllerHostElement;

Expand Down
Loading
Loading