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
26 changes: 19 additions & 7 deletions docs/website/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,26 @@ BugDrop is built with a privacy-first approach:

### Screenshot masking

You can mark sensitive elements so BugDrop visually covers them in supported screenshot modes. Add the `data-bugdrop-mask` attribute to any element you want covered:
You can mark sensitive elements so BugDrop visually covers them in supported screenshot modes. Add `data-bugdrop-redact` or `data-bugdrop-mask` to any element you want covered:

```html
<input type="email" data-bugdrop-mask />
<input type="email" data-bugdrop-redact />

<div data-bugdrop-mask>
<span>Customer name</span>
<span>customer@example.com</span>
</div>
```

When a user submits feedback, BugDrop paints an opaque rectangle over each tagged
element's bounding box on the captured PNG before showing the user the annotator
preview. The user sees what is masked and can audit it before submitting.
Supported explicit attributes are `data-bugdrop-redact`, `data-bd-redact`,
`data-bugdrop-redacted`, and `data-bugdrop-mask`.

When a user submits feedback, BugDrop plans redactions from matching DOM
elements, then paints an opaque rectangle over each target's measured bounding
box on supported captured PNGs. In manual screenshot flows, the masked image is
shown in the annotator preview so the user can audit it before submitting. In
automatic screenshot mode, BugDrop applies supported masks but submits without
showing the preview step.

Masking is best-effort visual coverage, not a data-loss-prevention or security
boundary. Users should still review screenshots before submitting when the manual
Expand All @@ -108,8 +114,14 @@ prevents gaps from CSS `gap` or non-masked siblings inside a masked container.

**Known limitations:**

- Elements inside Shadow DOM and cross-origin iframes are not traversed in this
iteration.
- Elements inside open Shadow DOM are traversed when the browser exposes the
shadow root. Closed Shadow DOM cannot be traversed; mark the host element if
the whole custom control should be covered.
- Iframe contents are not traversed. Mark the iframe element itself if the whole
embedded frame should be visually covered.
- BugDrop does not inspect pixels or text inside canvas, image, video, plugin,
or iframe content. Mark the containing DOM element if that entire region should
be visually covered.
- Mask rectangles are collected at the start of capture. If the page reflows or reveals
sensitive elements between collection and the moment `html-to-image` finishes
rendering, the mask may not cover the final pixels. Keep masked content stable during
Expand Down
109 changes: 108 additions & 1 deletion e2e/widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
});
});

test.describe('Widget Interaction', () => {

Check warning on line 44 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Arrow function has too many lines (344). Maximum allowed is 150
test('clicking feedback button triggers modal', async ({ page }) => {
await page.goto('/test/');

Expand Down Expand Up @@ -325,11 +325,11 @@
await page.goto('/test/');

// Wait for BugDrop API to be available, then open programmatically
await page.waitForFunction(() => typeof (window as any).BugDrop !== 'undefined', {

Check warning on line 328 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
timeout: 5000,
});
await page.evaluate(() => {
(window as any).BugDrop?.open();

Check warning on line 332 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
});

// Form should appear directly (no welcome screen)
Expand Down Expand Up @@ -458,7 +458,7 @@
await page.route('**/api/check/**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',

Check warning on line 461 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

File has too many lines (3520). Maximum allowed is 300
body: JSON.stringify({ installed: true }),
});
});
Expand Down Expand Up @@ -615,7 +615,7 @@
});
});

test.describe('Dismissible Button', () => {

Check warning on line 618 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Arrow function has too many lines (328). Maximum allowed is 150
test.beforeEach(async ({ page }) => {
// Clear localStorage before each test
await page.goto('/test/dismissible.html');
Expand Down Expand Up @@ -1240,10 +1240,10 @@

// Track host-page keydown events that fire while BugDrop is open
await page.evaluate(() => {
(window as any).__hostKeystrokeCount = 0;

Check warning on line 1243 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
document.addEventListener('keydown', () => {
if (document.getElementById('bugdrop-host')) {
(window as any).__hostKeystrokeCount++;

Check warning on line 1246 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
}
});
});
Expand Down Expand Up @@ -1279,7 +1279,7 @@
await expect(descInput).toHaveValue('Also testing textarea');

// Host page should NOT have received any keystrokes
const leakedCount = await page.evaluate(() => (window as any).__hostKeystrokeCount);

Check warning on line 1282 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
expect(leakedCount).toBe(0);
});
});
Expand Down Expand Up @@ -2184,7 +2184,7 @@
});
});

test.describe('Screenshot Crash Prevention (#67)', () => {

Check warning on line 2187 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Arrow function has too many lines (1383). Maximum allowed is 150
const STUB_PNG =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

Expand Down Expand Up @@ -2855,6 +2855,39 @@
await expect(errorText).not.toBeVisible({ timeout: 3000 });
});

test('privacy masking failure shows a dedicated modal and submits without a screenshot', async ({
page,
}) => {
const payloads = await trackFeedbackPayloads(page);
// toPng resolves with a data URL that fails Image.onload, so applyMaskToImage
// rejects with MaskApplicationError. Use /test/redaction.html so the page has
// a [data-bugdrop-redact] element — otherwise applyMaskToImage early-returns
// when rects.length === 0 and the failure path never fires.
await mockHtmlToImage(
page,
"function() { return Promise.resolve('data:image/png;base64,not-a-real-image'); }"
);
await page.goto('/test/redaction.html');
await navigateToFullPageCapture(page);

const host = page.locator('#bugdrop-host');
const modalTitle = host.locator('css=.bd-title');
await expect(modalTitle).toHaveText('Privacy masking failed', { timeout: 5000 });

await expect(host.locator('css=.bd-error-message__text')).toContainText(
'Automatic redaction of private fields could not be applied'
);
// Retry must NOT be offered for masking failures — retrying would fail the
// same way and a user might be tempted to send unredacted output.
await expect(host.locator('css=[data-action="retry"]')).not.toBeAttached();

await host.locator('css=[data-action="skip"]').click();

await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
expect(payloads).toHaveLength(1);
expect(payloads[0].screenshot).toBeNull();
});

test('retry button on error modal re-attempts capture', async ({ page }) => {
// First call fails, second call succeeds
await mockHtmlToImage(
Expand Down Expand Up @@ -3252,7 +3285,7 @@
});

test('remembers complex-page skip after element capture failure skip', async ({ page }) => {
await trackFeedbackSubmissions(page);
const payloads = await trackFeedbackPayloads(page);
await mockHtmlToImage(page, "function() { return Promise.reject(new Error('mock failure')); }");
await page.goto('/test/complex-dom.html?nodes=12000');

Expand All @@ -3271,13 +3304,36 @@
await expect(host.locator('css=.bd-error-message__text')).toBeVisible({ timeout: 5000 });
await host.locator('css=[data-action="skip"]').click();
await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
expect(payloads).toHaveLength(1);
expect((payloads[0].metadata as { elementSelector?: string }).elementSelector).toContain('h1');

await host.locator('css=.bd-close').click();
await host.locator('css=.bd-trigger').click();
await expect(host.locator('css=#title')).toBeVisible({ timeout: 5000 });
await expect(host.locator('css=#include-screenshot')).not.toBeChecked();
});

test('does not remember complex-page skip after element picker cancellation', async ({
page,
}) => {
const payloads = await trackFeedbackPayloads(page);
await page.goto('/test/complex-dom.html?nodes=12000');

const host = await navigateToScreenshotOptions(page);
await host.locator('css=[data-action="element"]').click();
await expect(page.locator('#bugdrop-element-picker-tooltip')).toBeVisible({ timeout: 5000 });
await page.keyboard.press('Escape');

await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
expect(payloads).toHaveLength(1);
expect(payloads[0].screenshot).toBeNull();

await host.locator('css=.bd-close').click();
await host.locator('css=.bd-trigger').click();
await expect(host.locator('css=#title')).toBeVisible({ timeout: 5000 });
await expect(host.locator('css=#include-screenshot')).toBeChecked();
});

test('shows all buttons on pages below 10k nodes', async ({ page }) => {
await mockHtmlToImage(page, spyToPng());
await page.goto('/test/complex-dom.html?nodes=4000');
Expand Down Expand Up @@ -4318,6 +4374,57 @@
expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
});

test('full-page capture masks transformed redaction targets', async ({ page }) => {
const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
page,
'/test/masking-layout-edge.html'
);

const rect = await docRectOf(page, '#transformed-mask');
const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);

expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
});

test('full-page capture masks sticky redaction targets after scrolling', async ({ page }) => {
await page.addInitScript(() => {
window.addEventListener('DOMContentLoaded', () => {
requestAnimationFrame(() => window.scrollTo(0, 420));
});
});

const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
page,
'/test/masking-layout-edge.html'
);

const rect = await docRectOf(page, '#sticky-mask');
const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);

expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
});

test('full-page capture masks fixed redaction targets after scrolling', async ({ page }) => {
await page.addInitScript(() => {
window.addEventListener('DOMContentLoaded', () => {
requestAnimationFrame(() => window.scrollTo(0, 420));
});
});

const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
page,
'/test/masking-layout-edge.html'
);

const rect = await docRectOf(page, '#fixed-mask');
const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);

expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
});

// Walk the element-picker flow and capture the chosen element.
//
// `selector` identifies the element to click in the picker. Because the picker
Expand Down
68 changes: 68 additions & 0 deletions public/test/masking-layout-edge.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>BugDrop - Masking Layout Edge Cases</title>
<style>
body {
margin: 0;
min-height: 1800px;
padding: 24px;
font-family: system-ui, sans-serif;
background: #f8fafc;
}

.spacer {
height: 360px;
}

.panel {
width: 260px;
min-height: 56px;
margin: 24px 0;
padding: 18px;
border: 1px solid #94a3b8;
border-radius: 4px;
background: #fee2e2;
color: #7f1d1d;
font-weight: 700;
}

#transformed-mask {
transform: translate(48px, 24px) rotate(1deg);
}

#sticky-mask {
position: sticky;
top: 16px;
background: #fde68a;
}

#fixed-mask {
position: fixed;
right: 32px;
top: 96px;
width: 220px;
background: #bfdbfe;
}
</style>
</head>
<body>
<h1>Masking Layout Edge Cases</h1>
<p>Fixture used by Playwright for transformed and sticky redaction geometry.</p>

<div id="transformed-mask" class="panel" data-bugdrop-redact>
transformed private account id
</div>

<div class="spacer"></div>

<div id="sticky-mask" class="panel" data-bugdrop-redact>sticky private session id</div>

<div class="spacer"></div>

<div id="fixed-mask" class="panel" data-bugdrop-redact>fixed private token</div>

<script src="/widget.js" data-repo="test-org/test-repo" data-screenshot="optional"></script>
</body>
</html>
89 changes: 89 additions & 0 deletions src/widget/annotation-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createAnnotator, type Tool } from './annotator';
import { createModal, redactionNoteHtml } from './ui';

export function showAnnotationStep(
root: HTMLElement,
screenshot: string,
redactionCount = 0,
opts?: { redactionUnavailable?: boolean }
): Promise<string | 'retake' | 'cancel'> {
return new Promise(resolve => {
let redactionNote = '';
if (opts?.redactionUnavailable) {
redactionNote = redactionNoteHtml(
'This browser viewport capture could not apply automatic private-field masks. Review and cover any sensitive areas before sending.'
);
} else if (redactionCount > 0) {
redactionNote = redactionNoteHtml(
`${redactionCount} private ${redactionCount === 1 ? 'item was' : 'items were'} marked for redaction in this screenshot. Review before sending.`
);
}
const modal = createModal(
root,
'Review Screenshot',
`
${redactionNote}
<p style="margin: 0 0 12px; color: var(--bd-text-secondary); font-size: 13px;">
Check that no sensitive information is visible before sending. Cover sensitive areas before submitting. Redactions are baked into the uploaded image.
</p>
<div class="bd-tools">
<button class="bd-tool active" data-tool="draw">✏️ Draw</button>
<button class="bd-tool" data-tool="arrow">➡️ Arrow</button>
<button class="bd-tool" data-tool="rect">▢ Rectangle</button>
<button class="bd-tool" data-tool="redact">Redact</button>
<button class="bd-tool" data-action="undo">↶ Undo</button>
</div>
<div id="annotation-canvas" class="bd-annotation-stage"></div>
<div class="bd-actions">
<button class="bd-btn bd-btn-secondary" data-action="retake">Retake</button>
<button class="bd-btn bd-btn-primary" data-action="done">Submit Feedback</button>
</div>
`,
false,
'bd-modal--annotator'
);

const canvasContainer = modal.querySelector('#annotation-canvas') as HTMLElement;
const annotator = createAnnotator(canvasContainer, screenshot);

const toolButtons = modal.querySelectorAll('[data-tool]');
toolButtons.forEach(btn => {
btn.addEventListener('click', e => {
const target = e.currentTarget as HTMLElement;
const tool = target.dataset.tool;

if (tool) {
toolButtons.forEach(b => b.classList.remove('active'));
target.classList.add('active');
annotator.setTool(tool as Tool);
}
});
});

const undoBtn = modal.querySelector('[data-action="undo"]') as HTMLElement | null;
undoBtn?.addEventListener('click', () => annotator.undo());

const closeBtn = modal.querySelector('.bd-close') as HTMLElement;
const retakeBtn = modal.querySelector('[data-action="retake"]') as HTMLElement;
const doneBtn = modal.querySelector('[data-action="done"]') as HTMLElement;

closeBtn?.addEventListener('click', () => {
annotator.destroy();
modal.remove();
resolve('cancel');
});

retakeBtn?.addEventListener('click', () => {
annotator.destroy();
modal.remove();
resolve('retake');
});

doneBtn?.addEventListener('click', () => {
const annotated = annotator.getImageData();
annotator.destroy();
modal.remove();
resolve(annotated);
});
});
}
Loading
Loading