Skip to content

Commit 559b779

Browse files
committed
fix: harden widget config input handling
1 parent 4d5e9f8 commit 559b779

8 files changed

Lines changed: 344 additions & 93 deletions

File tree

src/widget/index.ts

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

1827
type FeedbackCategory = 'bug' | 'feature' | 'question';
1928
type CategoryLabelConfig = Partial<Record<FeedbackCategory, string | string[]>>;
@@ -323,42 +332,68 @@ if (!document.currentScript) {
323332
'[BugDrop] document.currentScript is null — do not use async or defer on the BugDrop script tag.'
324333
);
325334
}
326-
const rawTheme = script?.dataset.theme as WidgetConfig['theme'] | undefined;
335+
const rawTheme = script?.dataset.theme;
336+
if (rawTheme && !isValidTheme(rawTheme)) {
337+
console.warn(`[BugDrop] Invalid data-theme "${rawTheme}". Expected "light", "dark", or "auto".`);
338+
}
339+
const requireName = script?.dataset.requireName === 'true';
340+
const requireEmail = script?.dataset.requireEmail === 'true';
341+
const rawPosition = script?.dataset.position;
342+
if (rawPosition && rawPosition !== 'bottom-right' && rawPosition !== 'bottom-left') {
343+
console.warn(
344+
`[BugDrop] Invalid data-position "${rawPosition}". Expected "bottom-right" or "bottom-left".`
345+
);
346+
}
347+
const rawDismissDuration = script?.dataset.dismissDuration;
348+
const dismissDuration = sanitizePositiveInteger(rawDismissDuration);
349+
if (rawDismissDuration && dismissDuration === undefined) {
350+
console.warn(
351+
'[BugDrop] Invalid data-dismiss-duration. Expected a positive whole number of days.'
352+
);
353+
}
354+
const rawScreenshotScale = script?.dataset.screenshotScale;
355+
const screenshotScale = sanitizeNonNegativeNumber(rawScreenshotScale);
356+
if (rawScreenshotScale && screenshotScale === undefined) {
357+
console.warn('[BugDrop] Invalid data-screenshot-scale. Expected a non-negative number.');
358+
}
359+
const rawShadow = script?.dataset.shadow;
360+
const shadow = sanitizeShadowPreset(rawShadow);
361+
if (rawShadow && !shadow) {
362+
console.warn('[BugDrop] Invalid data-shadow. Expected "soft", "hard", or "none".');
363+
}
327364
const config: WidgetConfig = {
328365
repo: script?.dataset.repo || '',
329366
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
367+
position: rawPosition === 'bottom-left' ? 'bottom-left' : 'bottom-right',
368+
theme: isValidTheme(rawTheme) ? rawTheme : 'auto', // Default to auto-detection
332369
// 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',
370+
showName: script?.dataset.showName === 'true' || requireName,
371+
requireName,
372+
showEmail: script?.dataset.showEmail === 'true' || requireEmail,
373+
requireEmail,
337374
// Dismissible button configuration
338375
buttonDismissible: script?.dataset.buttonDismissible === 'true',
339-
dismissDuration: script?.dataset.dismissDuration
340-
? parseInt(script.dataset.dismissDuration, 10)
341-
: undefined,
376+
dismissDuration,
342377
// Show restore pill after dismissing (default true when dismissible, unless explicitly false)
343378
showRestore: script?.dataset.showRestore !== 'false',
344379
// Button visibility (default true, set to false for API-only mode)
345380
showButton: script?.dataset.button !== 'false',
346381
// Custom accent color (e.g., "#FF6B35")
347-
accentColor: script?.dataset.color || undefined,
382+
accentColor: sanitizeCssColor(script?.dataset.color),
348383
// Custom icon URL (or 'none' to hide)
349-
iconUrl: script?.dataset.icon || undefined,
384+
iconUrl: sanitizeUrl(script?.dataset.icon),
350385
// Custom trigger label
351386
label: script?.dataset.label || undefined,
352387
categoryLabels: parseCategoryLabels(script?.dataset.categoryLabels),
353388
// 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,
389+
font: sanitizeCssFontFamily(script?.dataset.font),
390+
radius: sanitizeNonNegativeNumber(script?.dataset.radius)?.toString(),
391+
bgColor: sanitizeCssColor(script?.dataset.bg),
392+
textColor: sanitizeCssColor(script?.dataset.text),
358393
// Tier 2 styling customization
359-
borderWidth: script?.dataset.borderWidth || undefined,
360-
borderColor: script?.dataset.borderColor || undefined,
361-
shadow: script?.dataset.shadow || undefined,
394+
borderWidth: sanitizeNonNegativeNumber(script?.dataset.borderWidth)?.toString(),
395+
borderColor: sanitizeCssColor(script?.dataset.borderColor),
396+
shadow,
362397
// Welcome screen behavior (default: 'once')
363398
welcome: (() => {
364399
const val = script?.dataset.welcome;
@@ -377,9 +412,7 @@ const config: WidgetConfig = {
377412
}
378413
return 'optional' as const;
379414
})(),
380-
screenshotScale: script?.dataset.screenshotScale
381-
? parseFloat(script.dataset.screenshotScale)
382-
: undefined,
415+
screenshotScale,
383416
};
384417

385418
// Validate config
@@ -393,22 +426,41 @@ if (!config.repo) {
393426
initWidget(config);
394427
}
395428

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-
407429
// Build the trigger button label text
408430
function getTriggerLabel(config: WidgetConfig): string {
409431
return config.label !== undefined ? config.label : 'Feedback';
410432
}
411433

434+
function appendTriggerContent(trigger: HTMLElement, config: WidgetConfig): void {
435+
if (config.iconUrl !== 'none') {
436+
const icon = document.createElement('span');
437+
icon.className = 'bd-trigger-icon';
438+
439+
if (config.iconUrl) {
440+
const image = document.createElement('img');
441+
image.src = config.iconUrl;
442+
image.alt = '';
443+
const fallback = document.createElement('span');
444+
fallback.textContent = '🐛';
445+
fallback.style.display = 'none';
446+
image.addEventListener('error', () => {
447+
image.style.display = 'none';
448+
fallback.style.display = '';
449+
});
450+
icon.append(image, fallback);
451+
} else {
452+
icon.textContent = '🐛';
453+
}
454+
455+
trigger.appendChild(icon);
456+
}
457+
458+
const label = document.createElement('span');
459+
label.className = 'bd-trigger-label';
460+
label.textContent = getTriggerLabel(config);
461+
trigger.appendChild(label);
462+
}
463+
412464
// Create the pull tab shown after dismissing the button
413465
function createPullTab(root: HTMLElement, config: WidgetConfig): HTMLElement {
414466
const tab = document.createElement('div');
@@ -490,15 +542,14 @@ function initWidget(config: WidgetConfig) {
490542
if (shouldShowButton) {
491543
const trigger = document.createElement('button');
492544
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>`;
545+
appendTriggerContent(trigger, config);
495546
trigger.setAttribute('aria-label', 'Report a bug or send feedback');
496547

497548
// Add close button if dismissible
498549
if (config.buttonDismissible) {
499550
const closeBtn = document.createElement('button');
500551
closeBtn.className = 'bd-trigger-close';
501-
closeBtn.innerHTML = '×';
552+
closeBtn.textContent = '×';
502553
closeBtn.setAttribute('aria-label', 'Dismiss feedback button');
503554
trigger.appendChild(closeBtn);
504555

@@ -651,14 +702,13 @@ function exposeBugDropAPI(root: HTMLElement, config: WidgetConfig) {
651702
function createTriggerButton(root: HTMLElement, config: WidgetConfig, isRestoring = false) {
652703
const trigger = document.createElement('button');
653704
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>`;
705+
appendTriggerContent(trigger, config);
656706
trigger.setAttribute('aria-label', 'Report a bug or send feedback');
657707

658708
if (config.buttonDismissible) {
659709
const closeBtn = document.createElement('button');
660710
closeBtn.className = 'bd-trigger-close';
661-
closeBtn.innerHTML = '×';
711+
closeBtn.textContent = '×';
662712
closeBtn.setAttribute('aria-label', 'Dismiss feedback button');
663713
trigger.appendChild(closeBtn);
664714

@@ -1060,15 +1110,6 @@ function getCategoryChecked(
10601110
return (initialValues?.category || 'bug') === category ? 'checked' : '';
10611111
}
10621112

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-
10721113
async function submitFeedback(root: HTMLElement, config: WidgetConfig, data: FeedbackData) {
10731114
// Show submitting modal with loading state
10741115
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, sanitizeNonNegativeNumber } 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 = sanitizeNonNegativeNumber(style?.radius);
27+
const borderWidth = sanitizeNonNegativeNumber(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

src/widget/sanitize.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
const UNSAFE_CSS_TOKEN_PATTERN = /[;{}<>]|\/\*|\*\/|@import|url\s*\(|<\/style/i;
2+
const CSS_IDENT_PATTERN = /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/;
3+
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
4+
const CSS_FUNCTION_COLOR_PATTERN =
5+
/^(?:rgb|rgba|hsl|hsla)\(\s*[-+.\d%]+\s*(?:,\s*[-+.\d%]+\s*){2,3}\)$/i;
6+
7+
export function escapeHtml(value: string): string {
8+
return value
9+
.replace(/&/g, '&amp;')
10+
.replace(/</g, '&lt;')
11+
.replace(/>/g, '&gt;')
12+
.replace(/"/g, '&quot;')
13+
.replace(/'/g, '&#39;');
14+
}
15+
16+
export function sanitizeUrl(value: string | undefined): string | undefined {
17+
const trimmed = value?.trim();
18+
if (!trimmed || trimmed === 'none') return trimmed;
19+
20+
try {
21+
const url = new URL(trimmed, window.location.href);
22+
if (url.protocol === 'https:' || url.protocol === 'http:') {
23+
return trimmed;
24+
}
25+
} catch {
26+
return undefined;
27+
}
28+
29+
return undefined;
30+
}
31+
32+
export function sanitizeCssColor(value: string | undefined): string | undefined {
33+
const trimmed = value?.trim();
34+
if (!trimmed || UNSAFE_CSS_TOKEN_PATTERN.test(trimmed)) return undefined;
35+
if (HEX_COLOR_PATTERN.test(trimmed) || CSS_FUNCTION_COLOR_PATTERN.test(trimmed)) return trimmed;
36+
37+
if (CSS_IDENT_PATTERN.test(trimmed)) {
38+
return trimmed;
39+
}
40+
41+
if (typeof CSS !== 'undefined' && CSS.supports?.('color', trimmed)) {
42+
return trimmed;
43+
}
44+
45+
return undefined;
46+
}
47+
48+
export function sanitizeCssFontFamily(value: string | undefined): string | undefined {
49+
const trimmed = value?.trim();
50+
if (!trimmed) return undefined;
51+
if (trimmed === 'inherit') return trimmed;
52+
if (UNSAFE_CSS_TOKEN_PATTERN.test(trimmed)) return undefined;
53+
if (!/^[\w\s"',.-]+$/.test(trimmed)) return undefined;
54+
return trimmed;
55+
}
56+
57+
export function sanitizeNonNegativeNumber(value: string | undefined): number | undefined {
58+
const trimmed = value?.trim();
59+
if (!trimmed || !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(trimmed)) return undefined;
60+
61+
const parsed = Number(trimmed);
62+
return Number.isFinite(parsed) ? parsed : undefined;
63+
}
64+
65+
export function sanitizePositiveInteger(value: string | undefined): number | undefined {
66+
const trimmed = value?.trim();
67+
if (!trimmed || !/^[1-9]\d*$/.test(trimmed)) return undefined;
68+
69+
const parsed = Number(trimmed);
70+
return Number.isSafeInteger(parsed) ? parsed : undefined;
71+
}
72+
73+
export function sanitizeShadowPreset(
74+
value: string | undefined
75+
): 'none' | 'soft' | 'hard' | undefined {
76+
if (value === 'none' || value === 'soft' || value === 'hard') return value;
77+
return undefined;
78+
}

0 commit comments

Comments
 (0)