Skip to content

Commit e986a35

Browse files
committed
fix(sanitization): scope allowlist to the rich content path
1 parent 56d3260 commit e986a35

4 files changed

Lines changed: 66 additions & 23 deletions

File tree

core/src/components/select-option/select-option.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class SelectOption implements ComponentInterface {
2222
@Element() el!: HTMLElement;
2323

2424
/**
25-
* If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons.
25+
* If `true`, the user cannot interact with the select option.
2626
*/
2727
@Prop() disabled = false;
2828

core/src/utils/sanitization/index.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import { printIonError } from '@utils/logging';
44
* Sanitize an untrusted HTML string.
55
*
66
* Parses the string into a detached DOM, removes blocked tags, strips
7-
* attributes outside the `allowedAttributes` list (refer `sanitizeElement`),
8-
* and scrubs script-scheme URLs. Returns the sanitized HTML string.
7+
* attributes outside the narrow `domStringAllowedAttributes` list (refer
8+
* `sanitizeElement`), and scrubs script-scheme URLs. Returns the sanitized
9+
* HTML string.
910
*
1011
* Use this when you have an HTML string from an unknown source and need to
1112
* render it via `innerHTML`. Use `sanitizeDOMTree` instead when you already
1213
* have a DOM tree and want to sanitize it in place without a string round
13-
* trip; both apply the same attribute policy.
14+
* trip. The two apply the same dangerous-vector scrubbing but different
15+
* attribute allowlists: this path stays narrow so existing consumers
16+
* (toast, loading, alert message, etc.) are unaffected, while
17+
* `sanitizeDOMTree` uses the wider rich-content allowlist.
1418
*
1519
* @param untrustedString - The HTML string to sanitize. Pass an
1620
* `IonicSafeString` to bypass sanitization, or `undefined` to short-circuit.
@@ -70,7 +74,7 @@ export const sanitizeDOMString = (untrustedString: IonicSafeString | string | un
7074

7175
/* eslint-disable-next-line */
7276
for (let childIndex = 0; childIndex < childElements.length; childIndex++) {
73-
sanitizeElement(childElements[childIndex]);
77+
sanitizeElement(childElements[childIndex], domStringAllowedAttributes);
7478
}
7579
}
7680
});
@@ -85,7 +89,7 @@ export const sanitizeDOMString = (untrustedString: IonicSafeString | string | un
8589

8690
/* eslint-disable-next-line */
8791
for (let childIndex = 0; childIndex < dfChildren.length; childIndex++) {
88-
sanitizeElement(dfChildren[childIndex]);
92+
sanitizeElement(dfChildren[childIndex], domStringAllowedAttributes);
8993
}
9094

9195
// Append document fragment to div
@@ -106,8 +110,8 @@ export const sanitizeDOMString = (untrustedString: IonicSafeString | string | un
106110
* Sanitize an entire trusted DOM tree in place.
107111
*
108112
* Removes blocked tags (`script`, `iframe`, etc.) from the subtree and
109-
* then sanitizes attributes on every remaining element using the same
110-
* allowlist policy as `sanitizeDOMString` (refer `sanitizeElement`).
113+
* then sanitizes attributes on every remaining element using the wider
114+
* `richContentAllowedAttributes` allowlist (refer `sanitizeElement`).
111115
* Component presentational attributes (`size`, `color`, `shape`, inline
112116
* SVG, `aria-*`, `data-*`) are preserved; `style`, event handlers (`on*`),
113117
* form/navigation-hijack attributes, script-scheme URLs, and non-image
@@ -132,7 +136,7 @@ export const sanitizeDOMTree = (root: HTMLElement) => {
132136
}
133137
});
134138

135-
sanitizeElement(root);
139+
sanitizeElement(root, richContentAllowedAttributes, richContentAllowedAttributePrefixes);
136140
};
137141

138142
/**
@@ -141,7 +145,7 @@ export const sanitizeDOMTree = (root: HTMLElement) => {
141145
* clean those up as well
142146
*/
143147
// TODO(FW-2832): type (using Element triggers other type errors as well)
144-
const sanitizeElement = (element: any) => {
148+
const sanitizeElement = (element: any, allowedAttributes: string[], allowedAttributePrefixes: string[] = []) => {
145149
// IE uses childNodes, so ignore nodes that are not elements
146150
if (element.nodeType && element.nodeType !== 1) {
147151
return;
@@ -178,7 +182,7 @@ const sanitizeElement = (element: any) => {
178182
* (`formaction`, `action`, `target`), namespaced attributes like
179183
* `xlink:href`, and anything else not explicitly known to be safe.
180184
*/
181-
if (!isAttributeAllowed(lowerName)) {
185+
if (!isAttributeAllowed(lowerName, allowedAttributes, allowedAttributePrefixes)) {
182186
element.removeAttribute(attributeName);
183187
continue;
184188
}
@@ -229,7 +233,7 @@ const sanitizeElement = (element: any) => {
229233

230234
/* eslint-disable-next-line */
231235
for (let i = 0; i < childElements.length; i++) {
232-
sanitizeElement(childElements[i]);
236+
sanitizeElement(childElements[i], allowedAttributes, allowedAttributePrefixes);
233237
}
234238
};
235239

@@ -292,21 +296,33 @@ export const reflectPropertiesToAttributes = (root: Element): void => {
292296
};
293297

294298
/**
295-
* Attribute names that are always safe to keep. Covers global HTML
296-
* attributes, the Ionic component presentational props that cloned rich
297-
* content (e.g. `ion-select-option` markup) relies on, and the inert SVG
298-
* presentation attributes used by inline icons.
299+
* Attribute allowlist for `sanitizeDOMString`. Intentionally narrow: this
300+
* path sanitizes developer HTML strings rendered via `innerHTML` by
301+
* `ion-toast`, `ion-loading`, the `ion-alert` message,
302+
* `ion-refresher-content`, and `ion-infinite-scroll-content`, so the list
303+
* is kept to the minimum those have always needed. Broadening it here would
304+
* change sanitization output for all of those consumers, so the wider
305+
* rich-content allowlist below is deliberately scoped to `sanitizeDOMTree`
306+
* instead.
307+
*/
308+
const domStringAllowedAttributes = ['class', 'id', 'href', 'src', 'name', 'slot'];
309+
310+
/**
311+
* Attribute allowlist for `sanitizeDOMTree` (the select rich-content path).
312+
* Covers global HTML attributes, the Ionic component presentational props
313+
* that cloned rich content (e.g. `ion-select-option` markup) relies on, and
314+
* the inert SVG presentation attributes used by inline icons.
299315
*
300316
* `aria-*` and `data-*` are allowed separately by prefix (refer
301-
* `allowedAttributePrefixes`) since they are inert and not worth
317+
* `richContentAllowedAttributePrefixes`) since they are inert and not worth
302318
* enumerating. URL-bearing names (`href`, `src`) are allowed here, but
303319
* their values are still scrubbed for script schemes in `sanitizeElement`.
304320
*
305321
* Notably absent: `style`, event handlers (`on*`), and the
306322
* form/navigation-hijack attributes (`formaction`, `action`, `target`),
307323
* which are therefore stripped.
308324
*/
309-
const allowedAttributes = [
325+
const richContentAllowedAttributes = [
310326
// Global / structural
311327
'class',
312328
'id',
@@ -371,17 +387,21 @@ const allowedAttributes = [
371387
];
372388

373389
/**
374-
* Attribute-name prefixes that are always safe to keep. `aria-*` and
375-
* `data-*` attributes cannot execute script or load resources, so they are
376-
* allowed wholesale rather than enumerated by name.
390+
* Attribute-name prefixes that are always safe to keep in the rich-content
391+
* path. `aria-*` and `data-*` attributes cannot execute script or load
392+
* resources, so they are allowed wholesale rather than enumerated by name.
377393
*/
378-
const allowedAttributePrefixes = ['aria-', 'data-'];
394+
const richContentAllowedAttributePrefixes = ['aria-', 'data-'];
379395

380396
/**
381397
* Whether an attribute name (already lowercased) is safe to keep, by exact
382398
* match or by an allowed prefix.
383399
*/
384-
const isAttributeAllowed = (lowerName: string): boolean => {
400+
const isAttributeAllowed = (
401+
lowerName: string,
402+
allowedAttributes: string[],
403+
allowedAttributePrefixes: string[]
404+
): boolean => {
385405
if (allowedAttributes.includes(lowerName)) {
386406
return true;
387407
}

core/src/utils/sanitization/test/sanitization.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ describe('sanitizeDOMString', () => {
6262
)
6363
).toEqual('<ion-item><ion-label>Hello!</ion-label><ion-button>Click me</ion-button></ion-item>');
6464
});
65+
66+
it('strips rich-content attributes that are scoped to sanitizeDOMTree', () => {
67+
/**
68+
* Attributes only allowed by the wider sanitizeDOMTree (rich-content)
69+
* allowlist must still be stripped here. This keeps the output unchanged
70+
* for existing consumers (toast, loading, alert message, refresher and
71+
* infinite-scroll content) that run their innerHTML through
72+
* sanitizeDOMString.
73+
*/
74+
expect(
75+
sanitizeDOMString(
76+
'<ion-label class="lbl" type="button" value="x" width="40" mode="ios" aria-hidden="true" data-foo="bar">Hi</ion-label>'
77+
)
78+
).toEqual('<ion-label class="lbl">Hi</ion-label>');
79+
});
6580
});
6681

6782
describe('sanitizeDOMTree', () => {

core/src/utils/select-option-render.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ const renderClonedContent = (id: string, content: HTMLElement, className: string
8686
const Tag = useSpan ? 'span' : 'div';
8787
const keyPrefix = `${className}-${id}`;
8888

89+
/**
90+
* Do not remove. This is the only sanitization pass for callers that pass
91+
* an `HTMLElement` straight to `renderOptionLabel` (e.g. vanilla JS)
92+
* without going through `getOptionContent`, which sanitizes upstream.
93+
* `cloneToVNode` does pure structural conversion and no security
94+
* filtering, so dropping this call would reopen an XSS hole on the
95+
* direct `HTMLElement` path.
96+
*/
8997
sanitizeDOMTree(content);
9098

9199
return (

0 commit comments

Comments
 (0)