From 9c1aa1a0d2d1f5887e9b6f40ffb872e46d94ae38 Mon Sep 17 00:00:00 2001 From: neonwatty Date: Thu, 14 May 2026 16:15:43 -0700 Subject: [PATCH] fix: harden widget config input handling --- e2e/widget.spec.ts | 36 ++++++++- public/test/bugdrop-icon.svg | 4 + public/test/icon-custom.html | 6 +- src/widget/index.ts | 138 +++++++++++++++++++++++------------ src/widget/picker.ts | 20 +++-- src/widget/sanitize.ts | 87 ++++++++++++++++++++++ src/widget/theme.ts | 53 ++++++-------- src/widget/ui.ts | 24 ++++-- test/pickerStyle.test.ts | 48 ++++++++++++ test/sanitize.test.ts | 69 ++++++++++++++++++ test/theme.test.ts | 16 ++++ 11 files changed, 403 insertions(+), 98 deletions(-) create mode 100644 public/test/bugdrop-icon.svg create mode 100644 src/widget/sanitize.ts create mode 100644 test/pickerStyle.test.ts create mode 100644 test/sanitize.test.ts diff --git a/e2e/widget.spec.ts b/e2e/widget.spec.ts index 54826fd..0f2baae 100644 --- a/e2e/widget.spec.ts +++ b/e2e/widget.spec.ts @@ -2136,9 +2136,41 @@ test.describe('Custom Icon', () => { const img = page.locator('#bugdrop-host').locator('css=.bd-trigger-icon img'); await expect(img).toBeVisible(); - // Img src should contain the data URI + // Img src should contain the same-origin icon URL const src = await img.getAttribute('src'); - expect(src).toContain('data:image/png;base64,'); + expect(src).toContain('/test/bugdrop-icon.svg'); + }); + + test('hostile label and icon config cannot inject markup', async ({ page }) => { + await page.goto('/test/index.html'); + await page.locator('#bugdrop-host').locator('css=.bd-trigger').waitFor(); + + await page.evaluate(() => { + document.getElementById('bugdrop-host')?.remove(); + + const script = document.createElement('script'); + script.src = '/widget.js'; + script.dataset.repo = 'mean-weasel/bugdrop-widget-test'; + script.dataset.label = ''; + script.dataset.icon = 'javascript:window.__bugdropIconXss = true'; + document.body.appendChild(script); + }); + + const host = page.locator('#bugdrop-host'); + const trigger = host.locator('css=.bd-trigger'); + await expect(trigger).toBeVisible({ timeout: 5000 }); + + await expect(host.locator('css=.bd-trigger-label img')).not.toBeAttached(); + await expect(host.locator('css=.bd-trigger-icon img')).not.toBeAttached(); + await expect(host.locator('css=.bd-trigger-label')).toHaveText( + '' + ); + + const executed = await page.evaluate(() => ({ + label: Boolean((window as typeof window & { __bugdropLabelXss?: boolean }).__bugdropLabelXss), + icon: Boolean((window as typeof window & { __bugdropIconXss?: boolean }).__bugdropIconXss), + })); + expect(executed).toEqual({ label: false, icon: false }); }); test('broken icon URL falls back to default emoji', async ({ page }) => { diff --git a/public/test/bugdrop-icon.svg b/public/test/bugdrop-icon.svg new file mode 100644 index 0000000..9584cff --- /dev/null +++ b/public/test/bugdrop-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/test/icon-custom.html b/public/test/icon-custom.html index 5d8b678..e890e3a 100644 --- a/public/test/icon-custom.html +++ b/public/test/icon-custom.html @@ -25,11 +25,11 @@

Custom Icon Test

-

Test: This page loads BugDrop with data-icon set to a data URI image.

+

Test: This page loads BugDrop with data-icon set to a same-origin SVG image.

The feedback button should show a custom image instead of the default 🐛 emoji.

- - + + diff --git a/src/widget/index.ts b/src/widget/index.ts index ded6327..a746dcd 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -14,6 +14,16 @@ import { isValidTheme, type ThemeMode, } from './theme'; +import { + escapeHtml, + sanitizeCssColor, + sanitizeCssFontFamily, + sanitizeNonNegativeNumber, + sanitizeNonNegativePixelValue, + sanitizePositiveInteger, + sanitizeShadowPreset, + sanitizeUrl, +} from './sanitize'; type FeedbackCategory = 'bug' | 'feature' | 'question'; type CategoryLabelConfig = Partial>; @@ -323,42 +333,68 @@ if (!document.currentScript) { '[BugDrop] document.currentScript is null — do not use async or defer on the BugDrop script tag.' ); } -const rawTheme = script?.dataset.theme as WidgetConfig['theme'] | undefined; +const rawTheme = script?.dataset.theme; +if (rawTheme && !isValidTheme(rawTheme)) { + console.warn(`[BugDrop] Invalid data-theme "${rawTheme}". Expected "light", "dark", or "auto".`); +} +const requireName = script?.dataset.requireName === 'true'; +const requireEmail = script?.dataset.requireEmail === 'true'; +const rawPosition = script?.dataset.position; +if (rawPosition && rawPosition !== 'bottom-right' && rawPosition !== 'bottom-left') { + console.warn( + `[BugDrop] Invalid data-position "${rawPosition}". Expected "bottom-right" or "bottom-left".` + ); +} +const rawDismissDuration = script?.dataset.dismissDuration; +const dismissDuration = sanitizePositiveInteger(rawDismissDuration); +if (rawDismissDuration && dismissDuration === undefined) { + console.warn( + '[BugDrop] Invalid data-dismiss-duration. Expected a positive whole number of days.' + ); +} +const rawScreenshotScale = script?.dataset.screenshotScale; +const screenshotScale = sanitizeNonNegativeNumber(rawScreenshotScale); +if (rawScreenshotScale && screenshotScale === undefined) { + console.warn('[BugDrop] Invalid data-screenshot-scale. Expected a non-negative number.'); +} +const rawShadow = script?.dataset.shadow; +const shadow = sanitizeShadowPreset(rawShadow); +if (rawShadow && !shadow) { + console.warn('[BugDrop] Invalid data-shadow. Expected "soft", "hard", or "none".'); +} const config: WidgetConfig = { repo: script?.dataset.repo || '', apiUrl: script?.src.replace(/\/widget(?:\.v[\d.]+)?\.js$/, '/api') || '', - position: (script?.dataset.position as WidgetConfig['position']) || 'bottom-right', - theme: rawTheme || 'auto', // Default to auto-detection + position: rawPosition === 'bottom-left' ? 'bottom-left' : 'bottom-right', + theme: isValidTheme(rawTheme) ? rawTheme : 'auto', // Default to auto-detection // Name/email field configuration (all default to false for backwards compatibility) - showName: script?.dataset.showName === 'true', - requireName: script?.dataset.requireName === 'true', - showEmail: script?.dataset.showEmail === 'true', - requireEmail: script?.dataset.requireEmail === 'true', + showName: script?.dataset.showName === 'true' || requireName, + requireName, + showEmail: script?.dataset.showEmail === 'true' || requireEmail, + requireEmail, // Dismissible button configuration buttonDismissible: script?.dataset.buttonDismissible === 'true', - dismissDuration: script?.dataset.dismissDuration - ? parseInt(script.dataset.dismissDuration, 10) - : undefined, + dismissDuration, // Show restore pill after dismissing (default true when dismissible, unless explicitly false) showRestore: script?.dataset.showRestore !== 'false', // Button visibility (default true, set to false for API-only mode) showButton: script?.dataset.button !== 'false', // Custom accent color (e.g., "#FF6B35") - accentColor: script?.dataset.color || undefined, + accentColor: sanitizeCssColor(script?.dataset.color), // Custom icon URL (or 'none' to hide) - iconUrl: script?.dataset.icon || undefined, + iconUrl: sanitizeUrl(script?.dataset.icon), // Custom trigger label label: script?.dataset.label || undefined, categoryLabels: parseCategoryLabels(script?.dataset.categoryLabels), // Tier 1 styling customization - font: script?.dataset.font || undefined, - radius: script?.dataset.radius || undefined, - bgColor: script?.dataset.bg || undefined, - textColor: script?.dataset.text || undefined, + font: sanitizeCssFontFamily(script?.dataset.font), + radius: sanitizeNonNegativePixelValue(script?.dataset.radius)?.toString(), + bgColor: sanitizeCssColor(script?.dataset.bg), + textColor: sanitizeCssColor(script?.dataset.text), // Tier 2 styling customization - borderWidth: script?.dataset.borderWidth || undefined, - borderColor: script?.dataset.borderColor || undefined, - shadow: script?.dataset.shadow || undefined, + borderWidth: sanitizeNonNegativePixelValue(script?.dataset.borderWidth)?.toString(), + borderColor: sanitizeCssColor(script?.dataset.borderColor), + shadow, // Welcome screen behavior (default: 'once') welcome: (() => { const val = script?.dataset.welcome; @@ -377,9 +413,7 @@ const config: WidgetConfig = { } return 'optional' as const; })(), - screenshotScale: script?.dataset.screenshotScale - ? parseFloat(script.dataset.screenshotScale) - : undefined, + screenshotScale, }; // Validate config @@ -393,22 +427,41 @@ if (!config.repo) { initWidget(config); } -// Build the trigger button icon HTML - custom image with emoji fallback, 'none' to hide, or default emoji -function getTriggerIconHtml(config: WidgetConfig): string { - if (config.iconUrl === 'none') { - return ''; - } - if (config.iconUrl) { - return `🐛`; - } - return '🐛'; -} - // Build the trigger button label text function getTriggerLabel(config: WidgetConfig): string { return config.label !== undefined ? config.label : 'Feedback'; } +function appendTriggerContent(trigger: HTMLElement, config: WidgetConfig): void { + if (config.iconUrl !== 'none') { + const icon = document.createElement('span'); + icon.className = 'bd-trigger-icon'; + + if (config.iconUrl) { + const image = document.createElement('img'); + image.src = config.iconUrl; + image.alt = ''; + const fallback = document.createElement('span'); + fallback.textContent = '🐛'; + fallback.style.display = 'none'; + image.addEventListener('error', () => { + image.style.display = 'none'; + fallback.style.display = ''; + }); + icon.append(image, fallback); + } else { + icon.textContent = '🐛'; + } + + trigger.appendChild(icon); + } + + const label = document.createElement('span'); + label.className = 'bd-trigger-label'; + label.textContent = getTriggerLabel(config); + trigger.appendChild(label); +} + // Create the pull tab shown after dismissing the button function createPullTab(root: HTMLElement, config: WidgetConfig): HTMLElement { const tab = document.createElement('div'); @@ -490,15 +543,14 @@ function initWidget(config: WidgetConfig) { if (shouldShowButton) { const trigger = document.createElement('button'); trigger.className = 'bd-trigger'; - const iconHtml = getTriggerIconHtml(config); - trigger.innerHTML = `${iconHtml ? `${iconHtml}` : ''}${getTriggerLabel(config)}`; + appendTriggerContent(trigger, config); trigger.setAttribute('aria-label', 'Report a bug or send feedback'); // Add close button if dismissible if (config.buttonDismissible) { const closeBtn = document.createElement('button'); closeBtn.className = 'bd-trigger-close'; - closeBtn.innerHTML = '×'; + closeBtn.textContent = '×'; closeBtn.setAttribute('aria-label', 'Dismiss feedback button'); trigger.appendChild(closeBtn); @@ -651,14 +703,13 @@ function exposeBugDropAPI(root: HTMLElement, config: WidgetConfig) { function createTriggerButton(root: HTMLElement, config: WidgetConfig, isRestoring = false) { const trigger = document.createElement('button'); trigger.className = isRestoring ? 'bd-trigger bd-trigger--restoring' : 'bd-trigger'; - const iconHtml = getTriggerIconHtml(config); - trigger.innerHTML = `${iconHtml ? `${iconHtml}` : ''}${getTriggerLabel(config)}`; + appendTriggerContent(trigger, config); trigger.setAttribute('aria-label', 'Report a bug or send feedback'); if (config.buttonDismissible) { const closeBtn = document.createElement('button'); closeBtn.className = 'bd-trigger-close'; - closeBtn.innerHTML = '×'; + closeBtn.textContent = '×'; closeBtn.setAttribute('aria-label', 'Dismiss feedback button'); trigger.appendChild(closeBtn); @@ -1060,15 +1111,6 @@ function getCategoryChecked( return (initialValues?.category || 'bug') === category ? 'checked' : ''; } -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - async function submitFeedback(root: HTMLElement, config: WidgetConfig, data: FeedbackData) { // Show submitting modal with loading state const modal = createModal( diff --git a/src/widget/picker.ts b/src/widget/picker.ts index 9f48cdd..118b8d5 100644 --- a/src/widget/picker.ts +++ b/src/widget/picker.ts @@ -1,3 +1,5 @@ +import { sanitizeCssColor, sanitizeCssFontFamily, sanitizeNonNegativePixelValue } from './sanitize'; + export interface PickerStyle { accentColor?: string; font?: string; @@ -21,17 +23,21 @@ interface ResolvedPickerStyle { export function resolvePickerStyle(style?: PickerStyle): ResolvedPickerStyle { const isDark = style?.theme === 'dark'; + const radius = sanitizeNonNegativePixelValue(style?.radius); + const borderWidth = sanitizeNonNegativePixelValue(style?.borderWidth); + const fontFamily = sanitizeCssFontFamily(style?.font); + return { - accent: style?.accentColor || '#14b8a6', + accent: sanitizeCssColor(style?.accentColor) || '#14b8a6', fontFamily: style?.font === 'inherit' ? 'system-ui, sans-serif' - : style?.font || "'Space Grotesk', system-ui, sans-serif", - radius: style?.radius !== undefined ? `${style.radius}px` : '6px', - bw: style?.borderWidth || '3', - tooltipBg: style?.bgColor || (isDark ? '#0f172a' : '#1a1a1a'), - tooltipText: style?.textColor || '#f1f5f9', - tooltipBorder: style?.borderColor || (isDark ? '#334155' : '#333'), + : fontFamily || "'Space Grotesk', system-ui, sans-serif", + radius: radius !== undefined ? `${radius}px` : '6px', + bw: borderWidth !== undefined ? String(borderWidth) : '3', + tooltipBg: sanitizeCssColor(style?.bgColor) || (isDark ? '#0f172a' : '#1a1a1a'), + tooltipText: sanitizeCssColor(style?.textColor) || '#f1f5f9', + tooltipBorder: sanitizeCssColor(style?.borderColor) || (isDark ? '#334155' : '#333'), }; } diff --git a/src/widget/sanitize.ts b/src/widget/sanitize.ts new file mode 100644 index 0000000..24df21e --- /dev/null +++ b/src/widget/sanitize.ts @@ -0,0 +1,87 @@ +const UNSAFE_CSS_TOKEN_PATTERN = /[;{}<>]|\/\*|\*\/|@import|url\s*\(|<\/style/i; +const CSS_IDENT_PATTERN = /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/; +const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; +const CSS_FUNCTION_COLOR_PATTERN = + /^(?:rgb|rgba|hsl|hsla)\(\s*[-+.\d%]+\s*(?:,\s*[-+.\d%]+\s*){2,3}\)$/i; + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function sanitizeUrl(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || trimmed === 'none') return trimmed; + + try { + const url = new URL(trimmed, window.location.href); + if (url.protocol === 'https:' || url.protocol === 'http:') { + return trimmed; + } + } catch { + return undefined; + } + + return undefined; +} + +export function sanitizeCssColor(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || UNSAFE_CSS_TOKEN_PATTERN.test(trimmed)) return undefined; + if (HEX_COLOR_PATTERN.test(trimmed) || CSS_FUNCTION_COLOR_PATTERN.test(trimmed)) return trimmed; + + if (CSS_IDENT_PATTERN.test(trimmed)) { + return trimmed; + } + + if (typeof CSS !== 'undefined' && CSS.supports?.('color', trimmed)) { + return trimmed; + } + + return undefined; +} + +export function sanitizeCssFontFamily(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + if (trimmed === 'inherit') return trimmed; + if (UNSAFE_CSS_TOKEN_PATTERN.test(trimmed)) return undefined; + if (!/^[\w\s"',.-]+$/.test(trimmed)) return undefined; + return trimmed; +} + +export function sanitizeNonNegativeNumber(value: string | undefined): number | undefined { + const trimmed = value?.trim(); + if (!trimmed || !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(trimmed)) return undefined; + + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function sanitizeNonNegativePixelValue(value: string | undefined): number | undefined { + const trimmed = value?.trim(); + const match = trimmed?.match(/^((?:0|[1-9]\d*)(?:\.\d+)?)(?:px)?$/); + if (!match) return undefined; + + const parsed = Number(match[1]); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function sanitizePositiveInteger(value: string | undefined): number | undefined { + const trimmed = value?.trim(); + if (!trimmed || !/^[1-9]\d*$/.test(trimmed)) return undefined; + + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +export function sanitizeShadowPreset( + value: string | undefined +): 'none' | 'soft' | 'hard' | undefined { + if (value === 'none' || value === 'soft' || value === 'hard') return value; + return undefined; +} diff --git a/src/widget/theme.ts b/src/widget/theme.ts index 656218f..19a6ba2 100644 --- a/src/widget/theme.ts +++ b/src/widget/theme.ts @@ -1,3 +1,5 @@ +import { sanitizeCssColor, sanitizeNonNegativePixelValue, sanitizeShadowPreset } from './sanitize'; + // src/widget/theme.ts export type ThemeMode = 'light' | 'dark' | 'auto'; @@ -45,55 +47,42 @@ export function applyCustomStyles( const isDark = resolved === 'dark'; // Apply custom accent color if provided - if (config.accentColor) { - const color = config.accentColor; + const accentColor = sanitizeCssColor(config.accentColor); + if (accentColor) { + const color = accentColor; root.style.setProperty('--bd-primary', color); root.style.setProperty('--bd-primary-hover', `color-mix(in srgb, ${color} 85%, black)`); root.style.setProperty('--bd-border-focus', color); } // Apply custom background color if provided - if (config.bgColor) { - root.style.setProperty('--bd-bg-primary', config.bgColor); + const bgColor = sanitizeCssColor(config.bgColor); + if (bgColor) { + root.style.setProperty('--bd-bg-primary', bgColor); if (isDark) { - root.style.setProperty( - '--bd-bg-secondary', - `color-mix(in srgb, ${config.bgColor} 85%, white)` - ); - root.style.setProperty( - '--bd-bg-tertiary', - `color-mix(in srgb, ${config.bgColor} 70%, white)` - ); + root.style.setProperty('--bd-bg-secondary', `color-mix(in srgb, ${bgColor} 85%, white)`); + root.style.setProperty('--bd-bg-tertiary', `color-mix(in srgb, ${bgColor} 70%, white)`); } else { - root.style.setProperty( - '--bd-bg-secondary', - `color-mix(in srgb, ${config.bgColor} 93%, black)` - ); - root.style.setProperty( - '--bd-bg-tertiary', - `color-mix(in srgb, ${config.bgColor} 85%, black)` - ); + root.style.setProperty('--bd-bg-secondary', `color-mix(in srgb, ${bgColor} 93%, black)`); + root.style.setProperty('--bd-bg-tertiary', `color-mix(in srgb, ${bgColor} 85%, black)`); } } // Apply custom text color if provided - if (config.textColor) { - root.style.setProperty('--bd-text-primary', config.textColor); - const bgBase = config.bgColor || (isDark ? '#0f172a' : '#fafaf9'); + const textColor = sanitizeCssColor(config.textColor); + if (textColor) { + root.style.setProperty('--bd-text-primary', textColor); + const bgBase = bgColor || (isDark ? '#0f172a' : '#fafaf9'); root.style.setProperty( '--bd-text-secondary', - `color-mix(in srgb, ${config.textColor} 65%, ${bgBase})` - ); - root.style.setProperty( - '--bd-text-muted', - `color-mix(in srgb, ${config.textColor} 40%, ${bgBase})` + `color-mix(in srgb, ${textColor} 65%, ${bgBase})` ); + root.style.setProperty('--bd-text-muted', `color-mix(in srgb, ${textColor} 40%, ${bgBase})`); } // Apply custom border styling if provided - const parsedBorderW = config.borderWidth ? parseInt(config.borderWidth, 10) : null; - const borderW = parsedBorderW !== null && Number.isFinite(parsedBorderW) ? parsedBorderW : null; - const borderC = config.borderColor || null; + const borderW = sanitizeNonNegativePixelValue(config.borderWidth) ?? null; + const borderC = sanitizeCssColor(config.borderColor) || null; if (borderW !== null || borderC !== null) { const bw = borderW !== null ? `${borderW}px` : '1px'; const bc = borderC || 'var(--bd-border)'; @@ -102,7 +91,7 @@ export function applyCustomStyles( } // Apply shadow preset if provided - const shadowPreset = config.shadow || null; + const shadowPreset = sanitizeShadowPreset(config.shadow) || null; if (shadowPreset === 'none') { root.style.setProperty('--bd-shadow-sm', 'none'); root.style.setProperty('--bd-shadow-md', 'none'); diff --git a/src/widget/ui.ts b/src/widget/ui.ts index 9aa4336..2ab1d1f 100644 --- a/src/widget/ui.ts +++ b/src/widget/ui.ts @@ -1,4 +1,10 @@ import { resolveTheme, applyThemeClass, applyCustomStyles } from './theme'; +import { + escapeHtml, + sanitizeCssFontFamily, + sanitizeNonNegativePixelValue, + sanitizeUrl, +} from './sanitize'; declare const __BUGDROP_VERSION__: string; @@ -23,7 +29,8 @@ export function injectStyles(shadow: ShadowRoot, config: WidgetConfig) { // Determine font settings const useInheritFont = config.font === 'inherit'; - const customFont = config.font && config.font !== 'inherit' ? config.font : null; + const customFont = + config.font && config.font !== 'inherit' ? sanitizeCssFontFamily(config.font) : null; const fontImport = useInheritFont || customFont ? '' @@ -35,13 +42,13 @@ export function injectStyles(shadow: ShadowRoot, config: WidgetConfig) { : `'Space Grotesk', system-ui, sans-serif`; // Determine radius settings - const radiusPx = config.radius !== undefined ? parseInt(config.radius, 10) : null; + const radiusPx = sanitizeNonNegativePixelValue(config.radius) ?? null; const radiusSm = radiusPx !== null ? `${radiusPx}px` : '6px'; const radiusMd = radiusPx !== null ? `${Math.round(radiusPx * 1.4)}px` : '10px'; const radiusLg = radiusPx !== null ? `${Math.round(radiusPx * 2)}px` : '14px'; // Determine border width for CSS variable (still needed by the style block below) - const borderW = config.borderWidth ? parseInt(config.borderWidth, 10) : null; + const borderW = sanitizeNonNegativePixelValue(config.borderWidth) ?? null; const styles = document.createElement('style'); styles.textContent = ` @@ -1085,7 +1092,7 @@ export function createModal( overlay.innerHTML = `
-

${title}

+

${escapeHtml(title)}

@@ -1106,15 +1113,20 @@ export function showSuccessModal( isPublic: boolean ): Promise { return new Promise(resolve => { + const safeIssueUrl = sanitizeUrl(issueUrl); const issueInfo = isPublic ? `

Issue #${issueNumber} has been created.

- + ${ + safeIssueUrl + ? ` View on GitHub - + ` + : '' + } ` : `

Your feedback has been submitted successfully.

`; diff --git a/test/pickerStyle.test.ts b/test/pickerStyle.test.ts new file mode 100644 index 0000000..b9203ad --- /dev/null +++ b/test/pickerStyle.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { resolvePickerStyle } from '../src/widget/picker'; + +describe('resolvePickerStyle', () => { + it('passes through valid style values', () => { + expect( + resolvePickerStyle({ + accentColor: '#2563eb', + font: 'Inter, system-ui, sans-serif', + radius: '8', + borderWidth: '2', + bgColor: '#ffffff', + textColor: '#111827', + borderColor: '#d1d5db', + }) + ).toEqual({ + accent: '#2563eb', + fontFamily: 'Inter, system-ui, sans-serif', + radius: '8px', + bw: '2', + tooltipBg: '#ffffff', + tooltipText: '#111827', + tooltipBorder: '#d1d5db', + }); + }); + + it('falls back when style values contain CSS-breaking tokens', () => { + expect( + resolvePickerStyle({ + accentColor: 'red; } .hostile { color: red }', + font: 'Inter; color: red', + radius: '8em', + borderWidth: '-1', + bgColor: 'url(https://example.com/x)', + textColor: '', + borderColor: '#000; color: red', + }) + ).toEqual({ + accent: '#14b8a6', + fontFamily: "'Space Grotesk', system-ui, sans-serif", + radius: '6px', + bw: '3', + tooltipBg: '#1a1a1a', + tooltipText: '#f1f5f9', + tooltipBorder: '#333', + }); + }); +}); diff --git a/test/sanitize.test.ts b/test/sanitize.test.ts new file mode 100644 index 0000000..bbd8f39 --- /dev/null +++ b/test/sanitize.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { + escapeHtml, + sanitizeCssColor, + sanitizeCssFontFamily, + sanitizeNonNegativeNumber, + sanitizeNonNegativePixelValue, + sanitizePositiveInteger, + sanitizeShadowPreset, + sanitizeUrl, +} from '../src/widget/sanitize'; + +describe('widget sanitizers', () => { + it('escapes text for HTML and attribute contexts', () => { + expect(escapeHtml(`">`)).toBe( + '"><img src=x onerror=alert(1)>' + ); + }); + + it('allows http image URLs and rejects executable or data URLs', () => { + expect(sanitizeUrl('https://example.com/icon.svg')).toBe('https://example.com/icon.svg'); + expect(sanitizeUrl('http://example.com/icon.svg')).toBe('http://example.com/icon.svg'); + expect(sanitizeUrl('none')).toBe('none'); + expect(sanitizeUrl('javascript:alert(1)')).toBeUndefined(); + expect(sanitizeUrl('data:image/svg+xml,')).toBeUndefined(); + }); + + it('allows ordinary CSS colors and rejects CSS-breaking tokens', () => { + expect(sanitizeCssColor('#2563eb')).toBe('#2563eb'); + expect(sanitizeCssColor('rgb(10, 20, 30)')).toBe('rgb(10, 20, 30)'); + expect(sanitizeCssColor('red')).toBe('red'); + expect(sanitizeCssColor('red; } .hostile { color: red }')).toBeUndefined(); + expect(sanitizeCssColor('url(https://example.com/x)')).toBeUndefined(); + expect(sanitizeCssColor('')).toBeUndefined(); + }); + + it('allows plain font family lists and rejects CSS injection tokens', () => { + expect(sanitizeCssFontFamily('Inter, system-ui, sans-serif')).toBe( + 'Inter, system-ui, sans-serif' + ); + expect(sanitizeCssFontFamily('"Space Grotesk", system-ui')).toBe('"Space Grotesk", system-ui'); + expect(sanitizeCssFontFamily('inherit')).toBe('inherit'); + expect(sanitizeCssFontFamily('Inter; color: red')).toBeUndefined(); + expect(sanitizeCssFontFamily('url(https://example.com/font.woff2)')).toBeUndefined(); + }); + + it('normalizes numeric and enum config values', () => { + expect(sanitizeNonNegativeNumber('0')).toBe(0); + expect(sanitizeNonNegativeNumber('8.5')).toBe(8.5); + expect(sanitizeNonNegativeNumber('-1')).toBeUndefined(); + expect(sanitizeNonNegativeNumber('8px')).toBeUndefined(); + + expect(sanitizeNonNegativePixelValue('0')).toBe(0); + expect(sanitizeNonNegativePixelValue('8.5')).toBe(8.5); + expect(sanitizeNonNegativePixelValue('8px')).toBe(8); + expect(sanitizeNonNegativePixelValue('8.5px')).toBe(8.5); + expect(sanitizeNonNegativePixelValue('-1px')).toBeUndefined(); + expect(sanitizeNonNegativePixelValue('8em')).toBeUndefined(); + + expect(sanitizePositiveInteger('30')).toBe(30); + expect(sanitizePositiveInteger('0')).toBeUndefined(); + expect(sanitizePositiveInteger('1.5')).toBeUndefined(); + + expect(sanitizeShadowPreset('soft')).toBe('soft'); + expect(sanitizeShadowPreset('hard')).toBe('hard'); + expect(sanitizeShadowPreset('0 0 4px red')).toBeUndefined(); + }); +}); diff --git a/test/theme.test.ts b/test/theme.test.ts index 96a94f4..c1fd874 100644 --- a/test/theme.test.ts +++ b/test/theme.test.ts @@ -181,6 +181,12 @@ describe('applyCustomStyles', () => { rootDark.style.getPropertyValue('--bd-primary-hover') ); }); + + it('ignores CSS-breaking accent values', () => { + const root = makeRoot(); + applyCustomStyles(root, { accentColor: 'red; } .hostile { color: red }' }, 'light'); + expect(root.getAttribute('style')).toBeFalsy(); + }); }); describe('bgColor', () => { @@ -258,6 +264,16 @@ describe('applyCustomStyles', () => { expect(root.style.getPropertyValue('--bd-border')).toBe('#000'); expect(root.style.getPropertyValue('--bd-border-style')).toBe('2px solid #000'); }); + + it('ignores invalid border width and border color values', () => { + const root = makeRoot(); + applyCustomStyles( + root, + { borderWidth: '2em', borderColor: '#000; } .hostile { color: red }' }, + 'light' + ); + expect(root.getAttribute('style')).toBeFalsy(); + }); }); describe('shadow', () => {