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
4 changes: 2 additions & 2 deletions .bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
"maxSize": "55.0 kB"
"maxSize": "55.25 kB"
},
{
"path": "./dist/js/bootstrap.js",
"maxSize": "60.25 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
"maxSize": "33.0 kB"
"maxSize": "33.25 kB"
}
],
"ci": {
Expand Down
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@
"urlize",
"urlquery",
"vbtn",
"viewbox",
"viewports",
"Vite",
"vstack",
Expand Down
5 changes: 4 additions & 1 deletion js/src/chips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
47 changes: 32 additions & 15 deletions js/src/nav-overflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,25 +163,40 @@ class NavOverflow extends BaseComponent {
return
}

const iconHtml = this._resolveIcon()
const iconSpan = `<span class="nav-overflow-icon">${iconHtml}</span>`
const textSpan = `<span class="nav-overflow-text">${this._config.moreText}</span>`
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 = `
<button class="nav-link nav-overflow-toggle" type="button" data-bs-toggle="menu" data-bs-placement="${this._config.menuPlacement}" aria-expanded="false">
${toggleContent}
</button>
<div class="${CLASS_NAME_OVERFLOW_MENU} menu"></div>
`

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<HTMLElement>(SELECTOR_OVERFLOW_TOGGLE)
this._overflowMenu = overflowItem.querySelector<HTMLElement>(SELECTOR_OVERFLOW_MENU)

this._overflowToggle = button
this._overflowMenu = menu
}

protected _resolveIcon(): string {
Expand All @@ -196,6 +212,7 @@ class NavOverflow extends BaseComponent {

customIconElement.remove()

// Returned HTML is sanitized in `_createOverflowMenu` before insertion.
return iconHtml
}

Expand Down
26 changes: 26 additions & 0 deletions js/src/util/sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <use> can load external SVG
// fragments. Apps that need sprites can extend this allowlist deliberately.
span: [],
i: []
}
// js-docs-end icon-allow-list

const uriAttributes = new Set([
'background',
'cite',
Expand Down
36 changes: 36 additions & 0 deletions js/tests/unit/chips.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,42 @@ describe('Chips', () => {

expect(chipsEl.querySelector('.chip-dismiss')).toBeNull()
})

it('should keep the default dismiss SVG after sanitization', () => {
const { chips, chipsEl } = makeChips()
chips.add('alpha')

const dismiss = chipsEl.querySelector('.chip-dismiss')
expect(dismiss.querySelector('svg')).not.toBeNull()
expect(dismiss.querySelector('line')).not.toBeNull()
})

it('should sanitize dismissIcon HTML before inserting it', () => {
const { chips, chipsEl } = makeChips({
dismissIcon: '<img src="x" onerror="window.__chipsXss=1"><svg class="safe-icon" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4"/></svg>'
})
chips.add('alpha')

const dismiss = chipsEl.querySelector('.chip-dismiss')
expect(dismiss.querySelector('img')).toBeNull()
expect(dismiss.innerHTML).not.toMatch(/onerror/i)
expect(dismiss.querySelector('svg.safe-icon')).not.toBeNull()
expect(window.__chipsXss).toBeUndefined()
})

it('should sanitize dismissIcon supplied via data attributes', () => {
const { chips, chipsEl } = makeChips(
null,
'<div class="chips" data-bs-dismiss-icon=\'<img src=x onerror="window.__chipsDataXss=1"><span class="icon-ok">x</span>\'></div>'
)
chips.add('alpha')

const dismiss = chipsEl.querySelector('.chip-dismiss')
expect(dismiss.querySelector('img')).toBeNull()
expect(dismiss.innerHTML).not.toMatch(/onerror/i)
expect(dismiss.querySelector('.icon-ok')).not.toBeNull()
expect(window.__chipsDataXss).toBeUndefined()
})
})

describe('remove', () => {
Expand Down
82 changes: 82 additions & 0 deletions js/tests/unit/nav-overflow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,88 @@ describe('NavOverflow', () => {

navOverflow.dispose()
})

it('should treat moreText as plain text, not HTML', () => {
fixtureEl.innerHTML = [
'<ul class="nav" data-bs-toggle="nav-overflow">',
' <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
'</ul>'
].join('')

const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]')
const navOverflow = new NavOverflow(navEl, {
moreText: '<img src=x onerror="window.__navTextXss=1">More'
})

const toggleText = navEl.querySelector('.nav-overflow-text')
expect(toggleText.querySelector('img')).toBeNull()
expect(toggleText.textContent).toEqual('<img src=x onerror="window.__navTextXss=1">More')
expect(window.__navTextXss).toBeUndefined()

navOverflow.dispose()
})

it('should sanitize moreIcon HTML before inserting it', () => {
fixtureEl.innerHTML = [
'<ul class="nav" data-bs-toggle="nav-overflow">',
' <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
'</ul>'
].join('')

const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]')
const navOverflow = new NavOverflow(navEl, {
moreIcon: '<img src=x onerror="window.__navIconXss=1"><span class="safe-icon">…</span>'
})

const iconContainer = navEl.querySelector('.nav-overflow-icon')
expect(iconContainer.querySelector('img')).toBeNull()
expect(iconContainer.innerHTML).not.toMatch(/onerror/i)
expect(iconContainer.querySelector('.safe-icon')).not.toBeNull()
expect(window.__navIconXss).toBeUndefined()

navOverflow.dispose()
})

it('should not let menuPlacement break out of its attribute', () => {
fixtureEl.innerHTML = [
'<ul class="nav" data-bs-toggle="nav-overflow">',
' <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
'</ul>'
].join('')

const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]')
const maliciousPlacement = 'bottom-end"><img class="broken-out" src=x onerror="window.__navPlacementXss=1">'
const navOverflow = new NavOverflow(navEl, {
menuPlacement: maliciousPlacement
})

const toggle = navEl.querySelector('.nav-overflow-toggle')
expect(toggle.getAttribute('data-bs-placement')).toEqual(maliciousPlacement)
expect(navEl.querySelector('img.broken-out')).toBeNull()
expect(window.__navPlacementXss).toBeUndefined()

navOverflow.dispose()
})

it('should sanitize markup from [data-bs-overflow-icon]', () => {
fixtureEl.innerHTML = [
'<ul class="nav" data-bs-toggle="nav-overflow">',
' <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
' <span data-bs-overflow-icon class="from-markup"><img src=x onerror="window.__navCustomIconXss=1"><i class="bi-ok"></i></span>',
'</ul>'
].join('')

const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]')
const navOverflow = new NavOverflow(navEl)

const iconContainer = navEl.querySelector('.nav-overflow-icon')
expect(iconContainer.querySelector('img')).toBeNull()
expect(iconContainer.innerHTML).not.toMatch(/onerror/i)
expect(iconContainer.querySelector('i.bi-ok')).not.toBeNull()
expect(window.__navCustomIconXss).toBeUndefined()

navOverflow.dispose()
})
})

describe('collapseBelow', () => {
Expand Down
37 changes: 36 additions & 1 deletion js/tests/unit/util/sanitizer.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DefaultAllowlist, sanitizeHtml } from '../../../src/util/sanitizer.js'
import { DefaultAllowlist, DefaultIconAllowlist, sanitizeHtml } from '../../../src/util/sanitizer.js'

describe('Sanitizer', () => {
describe('sanitizeHtml', () => {
Expand Down Expand Up @@ -165,5 +165,40 @@ describe('Sanitizer', () => {
expect(firstResult).toContain('src')
expect(secondResult).toContain('src')
})

it('should keep safe SVG icon markup with DefaultIconAllowlist', () => {
const template = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>'

const result = sanitizeHtml(template, DefaultIconAllowlist, null)

expect(result).toContain('<svg')
expect(result).toContain('<line')
expect(result).toContain('stroke-width')
// Sanitizer matches allowlist names in lowercase; `viewBox` must survive.
expect(result.toLowerCase()).toContain('viewbox')
})

it('should strip scripts, images, and event handlers from icon HTML', () => {
const template = '<svg class="ok" onload="alert(1)"><script>alert(2)</script><path d="M0 0"/><img src=x onerror="alert(3)"></svg>'

const result = sanitizeHtml(template, DefaultIconAllowlist, null)

expect(result).toContain('class="ok"')
expect(result).toContain('<path')
expect(result).not.toContain('<script')
expect(result).not.toContain('<img')
expect(result).not.toMatch(/onload/i)
expect(result).not.toMatch(/onerror/i)
})

it('should strip <use> from icon HTML to avoid external SVG loads', () => {
const template = '<svg><use href="https://evil.example/sprite.svg#icon"></use><path d="M0 0"/></svg>'

const result = sanitizeHtml(template, DefaultIconAllowlist, null)

expect(result).not.toContain('<use')
expect(result).not.toContain('evil.example')
expect(result).toContain('<path')
})
})
})
4 changes: 2 additions & 2 deletions site/src/content/docs/components/nav-overflow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,8 @@ const navOverflow = new bootstrap.NavOverflow(navElement, {
| `collapseBelow` | number\|string | `0` | Width threshold below which all items collapse into the overflow dropdown. Pass a breakpoint name (e.g., `'md'`) to resolve from `--bs-breakpoint-{name}`, or a number for a direct pixel value. `0` disables. |
| `iconPlacement` | string | `'start'` | Position of the icon relative to the text in the toggle button. Use `'start'` for before the text or `'end'` for after. |
| `menuPlacement` | string | `'bottom-end'` | Placement of the overflow dropdown menu, passed as `data-bs-placement` to the menu toggle. |
| `moreText` | string | `'More'` | Text label for the overflow toggle button. |
| `moreIcon` | string | `'<svg>...</svg>'` | SVG or HTML icon for the overflow toggle button. Overridden by a child element with `data-bs-overflow-icon` if present. |
| `moreText` | string | `'More'` | Text label for the overflow toggle button. Inserted as plain text. |
| `moreIcon` | string | `'<svg>...</svg>'` | SVG or HTML icon for the overflow toggle button. Passed through the [icon content sanitizer]([[docsref:/getting-started/javascript#sanitizer]]) before insertion. Overridden by a child element with `data-bs-overflow-icon` if present (also sanitized). |
| `threshold` | number | `0` | Minimum number of items to keep visible before showing overflow. |
</BsTable>

Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/forms/chips.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Options can be passed via data attributes or JavaScript:
| `maxChips` | number \| null | `null` | Maximum number of chips allowed. `null` for unlimited. |
| `placeholder` | string | `''` | Placeholder text for dynamically created inputs. |
| `dismissible` | boolean | `true` | Add dismiss buttons to created chips. |
| `dismissIcon` | string | `'<svg>...</svg>'` | HTML string for the dismiss button icon. |
| `dismissIcon` | string | `'<svg>...</svg>'` | HTML string for the dismiss button icon. Passed through the [icon content sanitizer]([[docsref:/getting-started/javascript#sanitizer]]) before insertion. |
| `createOnBlur` | boolean | `true` | Create chip from input value when the input loses focus. |
</BsTable>

Expand Down
12 changes: 9 additions & 3 deletions site/src/content/docs/getting-started/javascript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,19 @@ Every Bootstrap plugin exposes the following methods and static properties.

## Sanitizer

Our tooltip and popover components are able to render arbitrary HTML to the page if configured to do so.
To prevent cross-site scripting (XSS) attacks, these components use our built-in content sanitizer to sanitize any options which accept HTML before they are rendered to the page. Content sanitization is enabled by default.
Some JavaScript components can render or accept arbitrary HTML in their configuration. To prevent cross-site scripting (XSS) attacks, these components use our built-in content sanitizer to sanitize any options which accept HTML before they are rendered to the page. Content sanitization is enabled by default. In particular:

The tags and attributes allowed by default are as follows. Any tags or attributes not explicitly allowed will be removed during sanitization:
- Popovers and Tooltips can render arbitrary HTML to the page if configured to do so.
- Chips and Nav overflow can accept HTML for their icon options (`dismissIcon`, `moreIcon`, and `[data-bs-overflow-icon]`).

The tags and attributes allowed by default for tooltip and popover content are as follows. Any tags or attributes not explicitly allowed will be removed during sanitization:

<JsDocs name="allow-list" file="js/src/util/sanitizer.ts" removeIndentation={false} />

Icon options use a separate, tighter allowlist that covers the default SVG icons and common inline-icon markup:

<JsDocs name="icon-allow-list" file="js/src/util/sanitizer.ts" removeIndentation={false} />

<Callout type="warning">
**Exercise caution when using these advanced options.** Refer to [OWASP’s Cross Site Scripting Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) for more information. Vulnerabilities caused solely by disabling or modifying content sanitization are not considered within scope for Bootstrap’s security model.
</Callout>
Expand Down