diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json
index 82ebf2677d03..4befec87e020 100644
--- a/.bundlewatch.config.json
+++ b/.bundlewatch.config.json
@@ -38,7 +38,7 @@
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
- "maxSize": "55.0 kB"
+ "maxSize": "55.25 kB"
},
{
"path": "./dist/js/bootstrap.js",
@@ -46,7 +46,7 @@
},
{
"path": "./dist/js/bootstrap.min.js",
- "maxSize": "33.0 kB"
+ "maxSize": "33.25 kB"
}
],
"ci": {
diff --git a/.cspell.json b/.cspell.json
index 19b89343cc8f..5fc52b2be0e7 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -194,6 +194,7 @@
"urlize",
"urlquery",
"vbtn",
+ "viewbox",
"viewports",
"Vite",
"vstack",
diff --git a/js/src/chips.ts b/js/src/chips.ts
index 2f2e5f768944..28708c43e657 100644
--- a/js/src/chips.ts
+++ b/js/src/chips.ts
@@ -8,6 +8,7 @@
import BaseComponent from './base-component.js'
import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
+import { DefaultIconAllowlist, sanitizeHtml } from './util/sanitizer.js'
/**
* Constants
@@ -344,7 +345,9 @@ class Chips extends BaseComponent {
button.className = CLASS_NAME_CHIP_DISMISS
button.setAttribute('aria-label', 'Remove')
button.setAttribute('tabindex', '-1') // Not in tab order, chips handle keyboard
- button.innerHTML = this._config.dismissIcon
+ // dismissIcon accepts HTML (SVGs, icon fonts) and is also settable via the
+ // data API, so run it through the icon allowlist before insertion.
+ button.innerHTML = sanitizeHtml(this._config.dismissIcon, DefaultIconAllowlist)
return button
}
diff --git a/js/src/nav-overflow.ts b/js/src/nav-overflow.ts
index da19dae9a835..c643b82e77a3 100644
--- a/js/src/nav-overflow.ts
+++ b/js/src/nav-overflow.ts
@@ -8,6 +8,7 @@
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
+import { DefaultIconAllowlist, sanitizeHtml } from './util/sanitizer.js'
/**
* Constants
@@ -162,25 +163,40 @@ class NavOverflow extends BaseComponent {
return
}
- const iconHtml = this._resolveIcon()
- const iconSpan = `${iconHtml}`
- const textSpan = `${this._config.moreText}`
- const toggleContent = this._config.iconPlacement === 'end' ?
- `${textSpan}${iconSpan}` :
- `${iconSpan}${textSpan}`
-
+ // Build with DOM APIs instead of string templates so user-supplied
+ // moreText / menuPlacement / moreIcon cannot break out of their slots.
const overflowItem = document.createElement('li')
overflowItem.className = 'nav-item nav-overflow-item'
- overflowItem.innerHTML = `
-
-
- `
+ const button = document.createElement('button')
+ button.type = 'button'
+ button.className = 'nav-link nav-overflow-toggle'
+ button.setAttribute('data-bs-toggle', 'menu')
+ button.setAttribute('data-bs-placement', this._config.menuPlacement)
+ button.setAttribute('aria-expanded', 'false')
+
+ const iconSpan = document.createElement('span')
+ iconSpan.className = 'nav-overflow-icon'
+ iconSpan.innerHTML = sanitizeHtml(this._resolveIcon(), DefaultIconAllowlist)
+
+ const textSpan = document.createElement('span')
+ textSpan.className = 'nav-overflow-text'
+ textSpan.textContent = this._config.moreText
+
+ if (this._config.iconPlacement === 'end') {
+ button.append(textSpan, iconSpan)
+ } else {
+ button.append(iconSpan, textSpan)
+ }
+
+ const menu = document.createElement('div')
+ menu.className = `${CLASS_NAME_OVERFLOW_MENU} menu`
+
+ overflowItem.append(button, menu)
this._element.append(overflowItem)
- this._overflowToggle = overflowItem.querySelector(SELECTOR_OVERFLOW_TOGGLE)
- this._overflowMenu = overflowItem.querySelector(SELECTOR_OVERFLOW_MENU)
+
+ this._overflowToggle = button
+ this._overflowMenu = menu
}
protected _resolveIcon(): string {
@@ -196,6 +212,7 @@ class NavOverflow extends BaseComponent {
customIconElement.remove()
+ // Returned HTML is sanitized in `_createOverflowMenu` before insertion.
return iconHtml
}
diff --git a/js/src/util/sanitizer.ts b/js/src/util/sanitizer.ts
index 3f777f4e76ec..08f30ba10786 100644
--- a/js/src/util/sanitizer.ts
+++ b/js/src/util/sanitizer.ts
@@ -48,6 +48,32 @@ export const DefaultAllowlist: SanitizerAllowList = {
}
// js-docs-end allow-list
+// js-docs-start icon-allow-list
+/**
+ * Allowlist for icon HTML options (Chips `dismissIcon`, NavOverflow `moreIcon`,
+ * and markup supplied via `[data-bs-overflow-icon]`). Covers the default SVG
+ * icons plus common inline-icon markup. Event-handler attributes and tags not
+ * listed here are stripped by `sanitizeHtml`.
+ */
+export const DefaultIconAllowlist: SanitizerAllowList = {
+ '*': ['class', 'role', ARIA_ATTRIBUTE_PATTERN],
+ // Attribute names are matched lowercased (see allowedAttribute). `viewBox` is
+ // listed as `viewbox` so the default SVG icons keep their coordinate system.
+ svg: ['xmlns', 'width', 'height', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'focusable'],
+ path: ['d', 'fill', 'stroke', 'stroke-width', 'fill-rule', 'clip-rule'],
+ line: ['x1', 'y1', 'x2', 'y2', 'stroke', 'stroke-width', 'stroke-linecap'],
+ circle: ['cx', 'cy', 'r', 'fill', 'stroke', 'stroke-width'],
+ rect: ['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'stroke-width'],
+ polyline: ['points', 'fill', 'stroke', 'stroke-width'],
+ polygon: ['points', 'fill', 'stroke', 'stroke-width'],
+ g: ['fill', 'stroke', 'stroke-width', 'transform'],
+ // No `use` here: `href` / `xlink:href` on