Skip to content

Commit fef9f4b

Browse files
committed
feat: improve redaction screenshot ux
1 parent 033107e commit fef9f4b

17 files changed

Lines changed: 500 additions & 101 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ jobs:
197197
env:
198198
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
199199

200+
- name: Record expected preview widget asset
201+
run: |
202+
VERSION=$(git describe --tags --abbrev=0) npm run build:widget
203+
echo "EXPECTED_WIDGET_ORIGIN=https://bugdrop-preview.neonwatty.workers.dev" >> "$GITHUB_ENV"
204+
echo "EXPECTED_WIDGET_SHA256=$(shasum -a 256 public/widget.js | awk '{print $1}')" >> "$GITHUB_ENV"
205+
200206
- name: Run live E2E tests
201207
run: npx playwright test --project=chromium-live --workers=1
202208
env:

.github/workflows/live-tests.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ jobs:
6464
env:
6565
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
6666

67+
- name: Record expected widget asset
68+
run: |
69+
if [ "$LIVE_TARGET" = "preview" ]; then
70+
VERSION=$(git describe --tags --abbrev=0) npm run build:widget
71+
echo "EXPECTED_WIDGET_ORIGIN=https://bugdrop-preview.neonwatty.workers.dev" >> "$GITHUB_ENV"
72+
echo "EXPECTED_WIDGET_SHA256=$(shasum -a 256 public/widget.js | awk '{print $1}')" >> "$GITHUB_ENV"
73+
else
74+
echo "EXPECTED_WIDGET_ORIGIN=https://bugdrop.neonwatty.workers.dev" >> "$GITHUB_ENV"
75+
fi
76+
6777
- name: Run live E2E tests
6878
run: npx playwright test --project=chromium-live --workers=1
6979
env:

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ That's it! Users can now click the bug button to submit feedback as GitHub Issue
3131

3232
> **Important:** Do not add `async` or `defer` to the script tag — the widget needs synchronous loading to read its configuration.
3333
34-
> **CSP note:** If your site uses a Content Security Policy, add `https://cdn.jsdelivr.net` to your `script-src` directive to enable screenshot capture.
34+
> **CSP note:** If your site uses a Content Security Policy, add `https://bugdrop.neonwatty.workers.dev` to your `script-src` directive to enable the widget.
3535
3636
> **Branch protection:** BugDrop works with repos that have branch protection rules (required PRs, merge queues). Screenshots are stored on a dedicated `bugdrop-screenshots` branch that is auto-created on first use — no manual setup needed.
3737
3838
> **Security note:** BugDrop is not a spam or malware filtering service. Treat feedback and screenshots as unauthenticated user-generated content. Exclude `bugdrop-screenshots` from CI/deploy workflows, and self-host behind your own WAF/CAPTCHA/content controls for stricter environments.
3939
4040
## Features
4141

42-
- 🔒 **Privacy masking** — tag sensitive elements with `data-bugdrop-mask` and BugDrop covers them in the screenshot before it's submitted. Passwords and credit-card inputs are masked automatically.
42+
- 🔒 **Privacy masking** — tag sensitive elements with `data-bugdrop-mask` and BugDrop visually covers them in supported screenshot modes before submission. Passwords and credit-card inputs are masked automatically.
4343

4444
## Widget Options
4545

@@ -75,7 +75,7 @@ User clicks bug button → Widget captures screenshot → Worker authenticates v
7575
```
7676

7777
1. **Widget** loads in a Shadow DOM (isolated from your page styles)
78-
2. **Screenshot** captured client-side using html2canvas
78+
2. **Screenshot** captured client-side using html-to-image
7979
3. **Worker** (Cloudflare) exchanges GitHub App credentials for an installation token
8080
4. **GitHub API** creates the issue with the screenshot stored in `.bugdrop/` on a dedicated `bugdrop-screenshots` branch (auto-created on first use)
8181

docs/website/configuration.mdx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,27 @@ Control how screenshots are collected during the feedback flow.
218218
- **`auto`** -- Automatically captures a full-page screenshot after the form is submitted, without showing the manual screenshot picker or redaction step.
219219
- **`required`** -- Requires a screenshot before submission. Users can choose full page, element, or area, then annotate or redact before submitting.
220220

221-
Manual redaction is controlled by the person submitting feedback. Use developer-configured masking for fields that should never appear in screenshots, especially with automatic screenshots.
221+
Manual redaction is controlled by the person submitting feedback. Use developer-configured masking for fields that should be visually covered in supported screenshot modes, especially with automatic screenshots. Auto mode warns users that a full-page screenshot will be attached, but it does not show the manual picker or annotation review step.
222+
223+
### Developer-configured screenshot masking
224+
225+
BugDrop can visually mask fields that your app marks as private before a screenshot is uploaded:
226+
227+
```html
228+
<input data-bugdrop-redact value="sk_live_..." />
229+
<section data-bugdrop-mask>Private account details</section>
230+
```
231+
232+
Supported attributes:
233+
234+
- `data-bugdrop-redact`
235+
- `data-bd-redact`
236+
- `data-bugdrop-redacted`
237+
- `data-bugdrop-mask`
238+
239+
This is best-effort visual masking, not a data-loss-prevention or security boundary. BugDrop can only mask content it can measure in the page DOM. Unmarked sensitive information can still appear, and browser rendering limits can apply to canvas, image, video, iframe, SVG, shadow DOM, pseudo-element, and highly custom control content.
240+
241+
Selected-area screenshots are rendered to the selected dimensions and masks are translated into that crop, but the capture still runs client-side against the page DOM. Avoid screenshots entirely for pages where client-side screenshot rendering is unacceptable.
222242

223243
```html
224244
<!-- Automatically attach a full-page screenshot -->

docs/website/faq.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ Since BugDrop uses Shadow DOM for isolation, it does not conflict with any frame
4242

4343
### How does BugDrop handle screenshots?
4444

45-
BugDrop uses [html2canvas](https://html2canvas.hertzen.com/) to capture screenshots entirely on the client side. When a user clicks the screenshot button:
45+
BugDrop uses [html-to-image](https://github.com/bubkoo/html-to-image) to capture screenshots entirely on the client side. When a user clicks the screenshot button:
4646

47-
1. html2canvas renders the current page to an HTML Canvas element in the user's browser
47+
1. html-to-image renders the current page to an HTML Canvas element in the user's browser
4848
2. In optional and required manual screenshot flows, the user can annotate the screenshot and cover sensitive regions with opaque blocks
4949
3. The canvas is converted to a PNG image
5050
4. The image is sent to the BugDrop API along with the form submission
@@ -198,7 +198,7 @@ Check the following:
198198

199199
1. **Verify the script tag** -- Make sure it does not have `async` or `defer` attributes
200200
2. **Check the console** -- Open your browser's developer console and look for `[BugDrop]` messages or errors
201-
3. **Check CSP** -- If your site uses a Content Security Policy, make sure `https://bugdrop.neonwatty.workers.dev` and `https://cdn.jsdelivr.net` are allowed in `script-src`
201+
3. **Check CSP** -- If your site uses a Content Security Policy, make sure `https://bugdrop.neonwatty.workers.dev` is allowed in `script-src`
202202
4. **Check the repo** -- Ensure `data-repo` matches your GitHub repository path exactly (case-sensitive)
203203
5. **Check app installation** -- Verify the BugDrop GitHub App is installed on the repository
204204

@@ -216,8 +216,8 @@ If the widget appears but submissions fail:
216216
If issues are created but without screenshots:
217217

218218
1. **Check branch protection** -- The GitHub App needs permission to push to the `bugdrop-screenshots` branch. See the [Installation](/docs/installation) page for details on branch protection configuration.
219-
2. **Check CSP** -- The html2canvas library is loaded from `cdn.jsdelivr.net`. Make sure it is allowed by your Content Security Policy.
220-
3. **Cross-origin content** -- html2canvas cannot capture cross-origin iframes or images loaded without CORS headers. These elements may appear blank in the screenshot.
219+
2. **Check CSP** -- Make sure your Content Security Policy allows the BugDrop widget script.
220+
3. **Cross-origin content** -- Browser screenshot rendering cannot capture cross-origin iframes or images loaded without CORS headers. These elements may appear blank in the screenshot.
221221

222222
### Can I use BugDrop on a static site?
223223

docs/website/installation.mdx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,17 @@ See the [Configuration](/docs/configuration) and [Styling](/docs/styling) docs f
7373

7474
## Protecting sensitive data
7575

76-
If your page renders customer data, billing details, or any other content you do not
77-
want to appear in submitted screenshots, mark those elements with `data-bugdrop-mask`:
76+
If your page renders customer data, billing details, or any other content you want
77+
BugDrop to visually cover in supported screenshot modes, mark those elements with
78+
`data-bugdrop-mask`:
7879

7980
```html
8081
<div class="customer-row" data-bugdrop-mask>
8182
Jane Doe — jane@acme.com
8283
</div>
8384
```
8485

85-
BugDrop covers each marked element with an opaque rectangle on the captured screenshot.
86+
BugDrop covers each marked element with an opaque rectangle on supported captured screenshots.
8687
Password inputs and credit-card autocomplete fields are masked automatically. See
8788
[Screenshot masking](/security#screenshot-masking) on the Security page for details.
8889

@@ -107,13 +108,13 @@ The BugDrop script tag should **not** include the `async` or `defer` attributes.
107108

108109
### Content Security Policy (CSP)
109110

110-
If your site uses a Content Security Policy, you will need to allow `cdn.jsdelivr.net` in your CSP directives. BugDrop loads the html2canvas library from jsDelivr for screenshot functionality:
111+
If your site uses a Content Security Policy, allow the BugDrop worker domain in your CSP directives:
111112

112113
```
113-
Content-Security-Policy: script-src 'self' https://bugdrop.neonwatty.workers.dev https://cdn.jsdelivr.net;
114+
Content-Security-Policy: script-src 'self' https://bugdrop.neonwatty.workers.dev;
114115
```
115116

116-
If you have a strict CSP, make sure both the BugDrop worker domain and jsDelivr are included in your `script-src` directive.
117+
If you have a strict CSP, make sure the BugDrop worker domain is included in your `script-src` directive.
117118

118119
### Branch Protection and the Screenshots Branch
119120

docs/website/security.mdx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,14 @@ Treat screenshots as unauthenticated user-generated content. The hosted service
5353

5454
### Screenshot Format
5555

56-
Screenshots are captured client-side using [html2canvas](https://html2canvas.hertzen.com/), which renders the current page to a canvas element in the user's browser. The canvas is then converted to a PNG image and uploaded. This means:
56+
Screenshots are captured client-side using [html-to-image](https://github.com/bubkoo/html-to-image), which renders the current page to a canvas element in the user's browser. The canvas is then converted to a PNG image and uploaded. This means:
5757

5858
- The initial screenshot capture is rendered from what the user actually sees
5959
- No server-side rendering or page access is required
6060
- The screenshot is generated entirely in the user's browser before being sent to the API
6161
- Users can redact additional screenshot regions before submitting when using the manual screenshot flow
6262

63-
Manual redaction is user-driven and does not automatically detect sensitive content. It complements, but does not replace, developer-configured masking for fields that should never appear in screenshots, especially when using automatic screenshots.
63+
Manual redaction is user-driven and does not automatically detect sensitive content. It complements, but does not replace, developer-configured masking for fields that should be visually covered in supported screenshot modes, especially when using automatic screenshots.
6464

6565
Because clients are untrusted, the API validates screenshot uploads server-side before storing them. BugDrop currently accepts PNG data URLs only and rejects SVG, malformed base64, oversized payloads, and data that does not have a PNG file signature.
6666

@@ -78,8 +78,7 @@ BugDrop is built with a privacy-first approach:
7878

7979
### Screenshot masking
8080

81-
You can mark sensitive elements so they never appear in submitted screenshots. Add the
82-
`data-bugdrop-mask` attribute to any element you want covered:
81+
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:
8382

8483
```html
8584
<input type="email" data-bugdrop-mask />
@@ -94,6 +93,10 @@ When a user submits feedback, BugDrop paints an opaque rectangle over each tagge
9493
element's bounding box on the captured PNG before showing the user the annotator
9594
preview. The user sees what is masked and can audit it before submitting.
9695

96+
Masking is best-effort visual coverage, not a data-loss-prevention or security
97+
boundary. Users should still review screenshots before submitting when the manual
98+
screenshot flow is enabled.
99+
97100
**Inheritance.** When an ancestor has `data-bugdrop-mask`, the entire ancestor box is
98101
masked as a single rectangle. Descendants do not get individual rectangles — this
99102
prevents gaps from CSS `gap` or non-masked siblings inside a masked container.
@@ -121,8 +124,7 @@ prevents gaps from CSS `gap` or non-masked siblings inside a masked container.
121124
The only network requests BugDrop makes are:
122125

123126
1. Loading the widget script from Cloudflare Workers
124-
2. Loading html2canvas from cdn.jsdelivr.net (for screenshot functionality)
125-
3. Submitting the feedback form to the BugDrop Cloudflare Worker API
127+
2. Submitting the feedback form to the BugDrop Cloudflare Worker API
126128

127129
The API acts as a pass-through to the GitHub API -- it receives the form data, creates the issue and uploads the screenshot, and discards the data. Nothing is persisted on the Cloudflare Worker.
128130

e2e/widget.live.spec.ts

Lines changed: 41 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'node:crypto';
12
import { test, expect, type Locator, type Page } from '@playwright/test';
23

34
/**
@@ -13,6 +14,13 @@ import { test, expect, type Locator, type Page } from '@playwright/test';
1314
// Add Vercel deployment protection bypass headers only to Vercel requests
1415
// (not globally, which would cause CORS preflight failures on cross-origin APIs)
1516
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
17+
const expectedWidgetOrigin =
18+
process.env.EXPECTED_WIDGET_ORIGIN ||
19+
(process.env.LIVE_TARGET === 'preview'
20+
? 'https://bugdrop-preview.neonwatty.workers.dev'
21+
: 'https://bugdrop.neonwatty.workers.dev');
22+
const expectedWidgetSha256 = process.env.EXPECTED_WIDGET_SHA256;
23+
1624
if (bypassSecret) {
1725
test.beforeEach(async ({ context }) => {
1826
await context.route('**/*.vercel.app/**', async route => {
@@ -25,27 +33,8 @@ if (bypassSecret) {
2533
});
2634
}
2735

28-
async function mockLiveScreenshotCapture(page: Page) {
29-
await page.addInitScript(`window.__bugdropMockToPng = function(el, opts) {
30-
window.__captureOpts = opts;
31-
var canvas = document.createElement('canvas');
32-
var ctx = canvas.getContext('2d');
33-
canvas.width = 600;
34-
canvas.height = 300;
35-
ctx.fillStyle = '#ffffff';
36-
ctx.fillRect(0, 0, canvas.width, canvas.height);
37-
ctx.fillStyle = '#111827';
38-
ctx.font = '600 18px Arial';
39-
ctx.fillText('Live preview undo canvas', 24, 38);
40-
ctx.fillStyle = '#e5e7eb';
41-
ctx.fillRect(36, 82, 210, 128);
42-
ctx.fillRect(354, 82, 210, 128);
43-
ctx.fillStyle = '#6b7280';
44-
ctx.font = '14px Arial';
45-
ctx.fillText('First annotation area', 58, 150);
46-
ctx.fillText('Latest annotation area', 374, 150);
47-
return Promise.resolve(canvas.toDataURL('image/png'));
48-
};`);
36+
function sha256(buffer: Buffer): string {
37+
return createHash('sha256').update(buffer).digest('hex');
4938
}
5039

5140
async function mockInstalledRepo(page: Page) {
@@ -254,6 +243,15 @@ async function dragOnCanvas(
254243
await page.mouse.up();
255244
}
256245

246+
async function expectUsableCanvas(canvas: Locator) {
247+
await expect
248+
.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width))
249+
.toBeGreaterThan(0);
250+
await expect
251+
.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).height))
252+
.toBeGreaterThan(0);
253+
}
254+
257255
test.describe('Widget Loading (Live)', () => {
258256
test('widget loads and renders on cross-origin site', async ({ page }) => {
259257
const errors: string[] = [];
@@ -285,17 +283,26 @@ test.describe('Widget Loading (Live)', () => {
285283
expect(unexpectedErrors).toHaveLength(0);
286284
});
287285

288-
test('widget.js is served from the preview worker', async ({ request }) => {
289-
const headers: Record<string, string> = {};
290-
if (bypassSecret) {
291-
headers['x-vercel-protection-bypass'] = bypassSecret;
292-
}
293-
const response = await request.get('/', { headers });
286+
test('venue loads the expected deployed widget asset', async ({ page, request }) => {
287+
await page.goto(process.env.LIVE_TARGET === 'preview' ? '/' : '/test/');
288+
289+
const widgetSrc = await page.evaluate(() => {
290+
return (
291+
Array.from(document.scripts)
292+
.map(script => script.src)
293+
.find(src => src.includes('/widget.js')) || ''
294+
);
295+
});
296+
297+
expect(widgetSrc).toContain(`${expectedWidgetOrigin}/widget.js`);
298+
299+
const response = await request.get(widgetSrc);
294300
expect(response.ok()).toBeTruthy();
295301

296-
const html = await response.text();
297-
// The page should contain a script tag pointing to the bugdrop widget
298-
expect(html).toContain('widget.js');
302+
if (expectedWidgetSha256) {
303+
const body = await response.body();
304+
expect(sha256(body)).toBe(expectedWidgetSha256);
305+
}
299306
});
300307
});
301308

@@ -375,9 +382,9 @@ test.describe('Cross-Origin API (Live)', () => {
375382
// At least one API call should have been made
376383
expect(apiCalls.length).toBeGreaterThan(0);
377384

378-
// All API calls should go to the workers.dev domain (not the Vercel domain)
385+
// All API calls should go to the expected worker origin (not the Vercel domain)
379386
for (const url of apiCalls) {
380-
expect(url).toContain('workers.dev');
387+
expect(url).toContain(expectedWidgetOrigin);
381388
}
382389
});
383390

@@ -529,7 +536,6 @@ test.describe('Screenshot Capture (Live)', () => {
529536
});
530537

531538
test('annotation undo works on the deployed preview widget', async ({ page }) => {
532-
await mockLiveScreenshotCapture(page);
533539
const host = await openScreenshotOptions(page, 'Live preview annotation undo');
534540

535541
const captureBtn = host.locator('css=[data-action="capture"]');
@@ -539,7 +545,7 @@ test.describe('Screenshot Capture (Live)', () => {
539545
const canvas = host.locator('css=#annotation-canvas canvas');
540546
await expect(host.locator('css=.bd-modal--annotator')).toBeVisible({ timeout: 10_000 });
541547
await expect(canvas).toBeVisible({ timeout: 10_000 });
542-
await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width)).toBe(600);
548+
await expectUsableCanvas(canvas);
543549

544550
await dragOnCanvas(page, canvas, { x: 0.18, y: 0.28 }, { x: 0.42, y: 0.68 });
545551
await dragOnCanvas(page, canvas, { x: 0.58, y: 0.28 }, { x: 0.82, y: 0.68 });
@@ -558,7 +564,6 @@ test.describe('Screenshot Capture (Live)', () => {
558564

559565
test('redaction works on the deployed preview widget', async ({ page }) => {
560566
const payloads = await trackLiveFeedbackPayloads(page);
561-
await mockLiveScreenshotCapture(page);
562567
const host = await openScreenshotOptions(page, 'Live preview redaction');
563568

564569
const captureBtn = host.locator('css=[data-action="capture"]');
@@ -568,7 +573,7 @@ test.describe('Screenshot Capture (Live)', () => {
568573
const canvas = host.locator('css=#annotation-canvas canvas');
569574
await expect(host.locator('css=.bd-modal--annotator')).toBeVisible({ timeout: 10_000 });
570575
await expect(canvas).toBeVisible({ timeout: 10_000 });
571-
await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width)).toBe(600);
576+
await expectUsableCanvas(canvas);
572577

573578
await host.locator('css=[data-tool="redact"]').click();
574579
await dragOnCanvas(page, canvas, { x: 0.18, y: 0.28 }, { x: 0.42, y: 0.68 });

0 commit comments

Comments
 (0)