Skip to content

Commit 9c1aa1a

Browse files
committed
fix: harden widget config input handling
1 parent 4d5e9f8 commit 9c1aa1a

11 files changed

Lines changed: 403 additions & 98 deletions

File tree

e2e/widget.spec.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,9 +2136,41 @@ test.describe('Custom Icon', () => {
21362136
const img = page.locator('#bugdrop-host').locator('css=.bd-trigger-icon img');
21372137
await expect(img).toBeVisible();
21382138

2139-
// Img src should contain the data URI
2139+
// Img src should contain the same-origin icon URL
21402140
const src = await img.getAttribute('src');
2141-
expect(src).toContain('data:image/png;base64,');
2141+
expect(src).toContain('/test/bugdrop-icon.svg');
2142+
});
2143+
2144+
test('hostile label and icon config cannot inject markup', async ({ page }) => {
2145+
await page.goto('/test/index.html');
2146+
await page.locator('#bugdrop-host').locator('css=.bd-trigger').waitFor();
2147+
2148+
await page.evaluate(() => {
2149+
document.getElementById('bugdrop-host')?.remove();
2150+
2151+
const script = document.createElement('script');
2152+
script.src = '/widget.js';
2153+
script.dataset.repo = 'mean-weasel/bugdrop-widget-test';
2154+
script.dataset.label = '<img src=x onerror="window.__bugdropLabelXss = true">';
2155+
script.dataset.icon = 'javascript:window.__bugdropIconXss = true';
2156+
document.body.appendChild(script);
2157+
});
2158+
2159+
const host = page.locator('#bugdrop-host');
2160+
const trigger = host.locator('css=.bd-trigger');
2161+
await expect(trigger).toBeVisible({ timeout: 5000 });
2162+
2163+
await expect(host.locator('css=.bd-trigger-label img')).not.toBeAttached();
2164+
await expect(host.locator('css=.bd-trigger-icon img')).not.toBeAttached();
2165+
await expect(host.locator('css=.bd-trigger-label')).toHaveText(
2166+
'<img src=x onerror="window.__bugdropLabelXss = true">'
2167+
);
2168+
2169+
const executed = await page.evaluate(() => ({
2170+
label: Boolean((window as typeof window & { __bugdropLabelXss?: boolean }).__bugdropLabelXss),
2171+
icon: Boolean((window as typeof window & { __bugdropIconXss?: boolean }).__bugdropIconXss),
2172+
}));
2173+
expect(executed).toEqual({ label: false, icon: false });
21422174
});
21432175

21442176
test('broken icon URL falls back to default emoji', async ({ page }) => {

public/test/bugdrop-icon.svg

Lines changed: 4 additions & 0 deletions
Loading

public/test/icon-custom.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
<body>
2626
<h1>Custom Icon Test</h1>
2727
<div class="info">
28-
<p><strong>Test:</strong> This page loads BugDrop with <code>data-icon</code> set to a data URI image.</p>
28+
<p><strong>Test:</strong> This page loads BugDrop with <code>data-icon</code> set to a same-origin SVG image.</p>
2929
<p>The feedback button should show a custom image instead of the default 🐛 emoji.</p>
3030
</div>
3131

32-
<!-- Load BugDrop WITH custom icon (1x1 green PNG as data URI) -->
33-
<script src="/widget.js" data-repo="mean-weasel/bugdrop-widget-test" data-theme="dark" data-icon="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="></script>
32+
<!-- Load BugDrop WITH custom icon -->
33+
<script src="/widget.js" data-repo="mean-weasel/bugdrop-widget-test" data-theme="dark" data-icon="/test/bugdrop-icon.svg"></script>
3434
</body>
3535
</html>

src/widget/index.ts

Lines changed: 90 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ import {
1414
isValidTheme,
1515
type ThemeMode,
1616
} from './theme';
17+
import {
18+
escapeHtml,
19+
sanitizeCssColor,
20+
sanitizeCssFontFamily,
21+
sanitizeNonNegativeNumber,
22+
sanitizeNonNegativePixelValue,
23+
sanitizePositiveInteger,
24+
sanitizeShadowPreset,
25+
sanitizeUrl,
26+
} from './sanitize';
1727

1828
type FeedbackCategory = 'bug' | 'feature' | 'question';
1929
type CategoryLabelConfig = Partial<Record<FeedbackCategory, string | string[]>>;
@@ -323,42 +333,68 @@ if (!document.currentScript) {
323333
'[BugDrop] document.currentScript is null — do not use async or defer on the BugDrop script tag.'
324334
);
325335
}
326-
const rawTheme = script?.dataset.theme as WidgetConfig['theme'] | undefined;
336+
const rawTheme = script?.dataset.theme;
337+
if (rawTheme && !isValidTheme(rawTheme)) {
338+
console.warn(`[BugDrop] Invalid data-theme "${rawTheme}". Expected "light", "dark", or "auto".`);
339+
}
340+
const requireName = script?.dataset.requireName === 'true';
341+
const requireEmail = script?.dataset.requireEmail === 'true';
342+
const rawPosition = script?.dataset.position;
343+
if (rawPosition && rawPosition !== 'bottom-right' && rawPosition !== 'bottom-left') {
344+
console.warn(
345+
`[BugDrop] Invalid data-position "${rawPosition}". Expected "bottom-right" or "bottom-left".`
346+
);
347+
}
348+
const rawDismissDuration = script?.dataset.dismissDuration;
349+
const dismissDuration = sanitizePositiveInteger(rawDismissDuration);
350+
if (rawDismissDuration && dismissDuration === undefined) {
351+
console.warn(
352+
'[BugDrop] Invalid data-dismiss-duration. Expected a positive whole number of days.'
353+
);
354+
}
355+
const rawScreenshotScale = script?.dataset.screenshotScale;
356+
const screenshotScale = sanitizeNonNegativeNumber(rawScreenshotScale);
357+
if (rawScreenshotScale && screenshotScale === undefined) {
358+
console.warn('[BugDrop] Invalid data-screenshot-scale. Expected a non-negative number.');
359+
}
360+
const rawShadow = script?.dataset.shadow;
361+
const shadow = sanitizeShadowPreset(rawShadow);
362+
if (rawShadow && !shadow) {
363+
console.warn('[BugDrop] Invalid data-shadow. Expected "soft", "hard", or "none".');
364+
}
327365
const config: WidgetConfig = {
328366
repo: script?.dataset.repo || '',
329367
apiUrl: script?.src.replace(/\/widget(?:\.v[\d.]+)?\.js$/, '/api') || '',
330-
position: (script?.dataset.position as WidgetConfig['position']) || 'bottom-right',
331-
theme: rawTheme || 'auto', // Default to auto-detection
368+
position: rawPosition === 'bottom-left' ? 'bottom-left' : 'bottom-right',
369+
theme: isValidTheme(rawTheme) ? rawTheme : 'auto', // Default to auto-detection
332370
// Name/email field configuration (all default to false for backwards compatibility)
333-
showName: script?.dataset.showName === 'true',
334-
requireName: script?.dataset.requireName === 'true',
335-
showEmail: script?.dataset.showEmail === 'true',
336-
requireEmail: script?.dataset.requireEmail === 'true',
371+
showName: script?.dataset.showName === 'true' || requireName,
372+
requireName,
373+
showEmail: script?.dataset.showEmail === 'true' || requireEmail,
374+
requireEmail,
337375
// Dismissible button configuration
338376
buttonDismissible: script?.dataset.buttonDismissible === 'true',
339-
dismissDuration: script?.dataset.dismissDuration
340-
? parseInt(script.dataset.dismissDuration, 10)
341-
: undefined,
377+
dismissDuration,
342378
// Show restore pill after dismissing (default true when dismissible, unless explicitly false)
343379
showRestore: script?.dataset.showRestore !== 'false',
344380
// Button visibility (default true, set to false for API-only mode)
345381
showButton: script?.dataset.button !== 'false',
346382
// Custom accent color (e.g., "#FF6B35")
347-
accentColor: script?.dataset.color || undefined,
383+
accentColor: sanitizeCssColor(script?.dataset.color),
348384
// Custom icon URL (or 'none' to hide)
349-
iconUrl: script?.dataset.icon || undefined,
385+
iconUrl: sanitizeUrl(script?.dataset.icon),
350386
// Custom trigger label
351387
label: script?.dataset.label || undefined,
352388
categoryLabels: parseCategoryLabels(script?.dataset.categoryLabels),
353389
// Tier 1 styling customization
354-
font: script?.dataset.font || undefined,
355-
radius: script?.dataset.radius || undefined,
356-
bgColor: script?.dataset.bg || undefined,
357-
textColor: script?.dataset.text || undefined,
390+
font: sanitizeCssFontFamily(script?.dataset.font),
391+
radius: sanitizeNonNegativePixelValue(script?.dataset.radius)?.toString(),
392+
bgColor: sanitizeCssColor(script?.dataset.bg),
393+
textColor: sanitizeCssColor(script?.dataset.text),
358394
// Tier 2 styling customization
359-
borderWidth: script?.dataset.borderWidth || undefined,
360-
borderColor: script?.dataset.borderColor || undefined,
361-
shadow: script?.dataset.shadow || undefined,
395+
borderWidth: sanitizeNonNegativePixelValue(script?.dataset.borderWidth)?.toString(),
396+
borderColor: sanitizeCssColor(script?.dataset.borderColor),
397+
shadow,
362398
// Welcome screen behavior (default: 'once')
363399
welcome: (() => {
364400
const val = script?.dataset.welcome;
@@ -377,9 +413,7 @@ const config: WidgetConfig = {
377413
}
378414
return 'optional' as const;
379415
})(),
380-
screenshotScale: script?.dataset.screenshotScale
381-
? parseFloat(script.dataset.screenshotScale)
382-
: undefined,
416+
screenshotScale,
383417
};
384418

385419
// Validate config
@@ -393,22 +427,41 @@ if (!config.repo) {
393427
initWidget(config);
394428
}
395429

396-
// Build the trigger button icon HTML - custom image with emoji fallback, 'none' to hide, or default emoji
397-
function getTriggerIconHtml(config: WidgetConfig): string {
398-
if (config.iconUrl === 'none') {
399-
return '';
400-
}
401-
if (config.iconUrl) {
402-
return `<img src="${config.iconUrl}" alt="" onerror="this.style.display='none';this.nextSibling.style.display=''"><span style="display:none">🐛</span>`;
403-
}
404-
return '🐛';
405-
}
406-
407430
// Build the trigger button label text
408431
function getTriggerLabel(config: WidgetConfig): string {
409432
return config.label !== undefined ? config.label : 'Feedback';
410433
}
411434

435+
function appendTriggerContent(trigger: HTMLElement, config: WidgetConfig): void {
436+
if (config.iconUrl !== 'none') {
437+
const icon = document.createElement('span');
438+
icon.className = 'bd-trigger-icon';
439+
440+
if (config.iconUrl) {
441+
const image = document.createElement('img');
442+
image.src = config.iconUrl;
443+
image.alt = '';
444+
const fallback = document.createElement('span');
445+
fallback.textContent = '🐛';
446+
fallback.style.display = 'none';
447+
image.addEventListener('error', () => {
448+
image.style.display = 'none';
449+
fallback.style.display = '';
450+
});
451+
icon.append(image, fallback);
452+
} else {
453+
icon.textContent = '🐛';
454+
}
455+
456+
trigger.appendChild(icon);
457+
}
458+
459+
const label = document.createElement('span');
460+
label.className = 'bd-trigger-label';
461+
label.textContent = getTriggerLabel(config);
462+
trigger.appendChild(label);
463+
}
464+
412465
// Create the pull tab shown after dismissing the button
413466
function createPullTab(root: HTMLElement, config: WidgetConfig): HTMLElement {
414467
const tab = document.createElement('div');
@@ -490,15 +543,14 @@ function initWidget(config: WidgetConfig) {
490543
if (shouldShowButton) {
491544
const trigger = document.createElement('button');
492545
trigger.className = 'bd-trigger';
493-
const iconHtml = getTriggerIconHtml(config);
494-
trigger.innerHTML = `${iconHtml ? `<span class="bd-trigger-icon">${iconHtml}</span>` : ''}<span class="bd-trigger-label">${getTriggerLabel(config)}</span>`;
546+
appendTriggerContent(trigger, config);
495547
trigger.setAttribute('aria-label', 'Report a bug or send feedback');
496548

497549
// Add close button if dismissible
498550
if (config.buttonDismissible) {
499551
const closeBtn = document.createElement('button');
500552
closeBtn.className = 'bd-trigger-close';
501-
closeBtn.innerHTML = '×';
553+
closeBtn.textContent = '×';
502554
closeBtn.setAttribute('aria-label', 'Dismiss feedback button');
503555
trigger.appendChild(closeBtn);
504556

@@ -651,14 +703,13 @@ function exposeBugDropAPI(root: HTMLElement, config: WidgetConfig) {
651703
function createTriggerButton(root: HTMLElement, config: WidgetConfig, isRestoring = false) {
652704
const trigger = document.createElement('button');
653705
trigger.className = isRestoring ? 'bd-trigger bd-trigger--restoring' : 'bd-trigger';
654-
const iconHtml = getTriggerIconHtml(config);
655-
trigger.innerHTML = `${iconHtml ? `<span class="bd-trigger-icon">${iconHtml}</span>` : ''}<span class="bd-trigger-label">${getTriggerLabel(config)}</span>`;
706+
appendTriggerContent(trigger, config);
656707
trigger.setAttribute('aria-label', 'Report a bug or send feedback');
657708

658709
if (config.buttonDismissible) {
659710
const closeBtn = document.createElement('button');
660711
closeBtn.className = 'bd-trigger-close';
661-
closeBtn.innerHTML = '×';
712+
closeBtn.textContent = '×';
662713
closeBtn.setAttribute('aria-label', 'Dismiss feedback button');
663714
trigger.appendChild(closeBtn);
664715

@@ -1060,15 +1111,6 @@ function getCategoryChecked(
10601111
return (initialValues?.category || 'bug') === category ? 'checked' : '';
10611112
}
10621113

1063-
function escapeHtml(value: string): string {
1064-
return value
1065-
.replace(/&/g, '&amp;')
1066-
.replace(/</g, '&lt;')
1067-
.replace(/>/g, '&gt;')
1068-
.replace(/"/g, '&quot;')
1069-
.replace(/'/g, '&#39;');
1070-
}
1071-
10721114
async function submitFeedback(root: HTMLElement, config: WidgetConfig, data: FeedbackData) {
10731115
// Show submitting modal with loading state
10741116
const modal = createModal(

src/widget/picker.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { sanitizeCssColor, sanitizeCssFontFamily, sanitizeNonNegativePixelValue } from './sanitize';
2+
13
export interface PickerStyle {
24
accentColor?: string;
35
font?: string;
@@ -21,17 +23,21 @@ interface ResolvedPickerStyle {
2123

2224
export function resolvePickerStyle(style?: PickerStyle): ResolvedPickerStyle {
2325
const isDark = style?.theme === 'dark';
26+
const radius = sanitizeNonNegativePixelValue(style?.radius);
27+
const borderWidth = sanitizeNonNegativePixelValue(style?.borderWidth);
28+
const fontFamily = sanitizeCssFontFamily(style?.font);
29+
2430
return {
25-
accent: style?.accentColor || '#14b8a6',
31+
accent: sanitizeCssColor(style?.accentColor) || '#14b8a6',
2632
fontFamily:
2733
style?.font === 'inherit'
2834
? 'system-ui, sans-serif'
29-
: style?.font || "'Space Grotesk', system-ui, sans-serif",
30-
radius: style?.radius !== undefined ? `${style.radius}px` : '6px',
31-
bw: style?.borderWidth || '3',
32-
tooltipBg: style?.bgColor || (isDark ? '#0f172a' : '#1a1a1a'),
33-
tooltipText: style?.textColor || '#f1f5f9',
34-
tooltipBorder: style?.borderColor || (isDark ? '#334155' : '#333'),
35+
: fontFamily || "'Space Grotesk', system-ui, sans-serif",
36+
radius: radius !== undefined ? `${radius}px` : '6px',
37+
bw: borderWidth !== undefined ? String(borderWidth) : '3',
38+
tooltipBg: sanitizeCssColor(style?.bgColor) || (isDark ? '#0f172a' : '#1a1a1a'),
39+
tooltipText: sanitizeCssColor(style?.textColor) || '#f1f5f9',
40+
tooltipBorder: sanitizeCssColor(style?.borderColor) || (isDark ? '#334155' : '#333'),
3541
};
3642
}
3743

0 commit comments

Comments
 (0)