diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c539699..0a076ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -141,6 +143,8 @@ jobs: PLAYWRIGHT_BASE_URL: https://bugdrop-widget-test-git-preview-jermwatts-projects.vercel.app steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -197,6 +201,28 @@ jobs: env: VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + - name: Record expected preview widget asset + run: | + VERSION=$(git describe --tags --abbrev=0) npm run build:widget + echo "EXPECTED_WIDGET_ORIGIN=https://bugdrop-preview.neonwatty.workers.dev" >> "$GITHUB_ENV" + echo "EXPECTED_WIDGET_SHA256=$(shasum -a 256 public/widget.js | awk '{print $1}')" >> "$GITHUB_ENV" + + - name: Wait for expected preview widget asset + run: | + WIDGET_URL="https://bugdrop-preview.neonwatty.workers.dev/widget.js" + echo "Waiting for $WIDGET_URL to serve $EXPECTED_WIDGET_SHA256..." + for i in $(seq 1 30); do + ACTUAL_SHA="$(curl -sSf "$WIDGET_URL" | shasum -a 256 | awk '{print $1}')" + if [ "$ACTUAL_SHA" = "$EXPECTED_WIDGET_SHA256" ]; then + echo "Preview widget asset matched after $((i * 5))s" + exit 0 + fi + echo "Attempt $i/30 served $ACTUAL_SHA; waiting 5s..." + sleep 5 + done + echo "Preview widget did not serve expected asset $EXPECTED_WIDGET_SHA256" + exit 1 + - name: Run live E2E tests run: npx playwright test --project=chromium-live --workers=1 env: diff --git a/.github/workflows/live-tests.yml b/.github/workflows/live-tests.yml index c7cb5f8..c6f69be 100644 --- a/.github/workflows/live-tests.yml +++ b/.github/workflows/live-tests.yml @@ -24,6 +24,8 @@ jobs: PLAYWRIGHT_BASE_URL: https://bugdrop-widget-test-git-preview-jermwatts-projects.vercel.app steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -64,6 +66,33 @@ jobs: env: VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + - name: Record expected widget asset + run: | + if [ "$LIVE_TARGET" = "preview" ]; then + VERSION=$(git describe --tags --abbrev=0) npm run build:widget + echo "EXPECTED_WIDGET_ORIGIN=https://bugdrop-preview.neonwatty.workers.dev" >> "$GITHUB_ENV" + echo "EXPECTED_WIDGET_SHA256=$(shasum -a 256 public/widget.js | awk '{print $1}')" >> "$GITHUB_ENV" + else + echo "EXPECTED_WIDGET_ORIGIN=https://bugdrop.neonwatty.workers.dev" >> "$GITHUB_ENV" + fi + + - name: Wait for expected preview widget asset + if: env.LIVE_TARGET == 'preview' + run: | + WIDGET_URL="https://bugdrop-preview.neonwatty.workers.dev/widget.js" + echo "Waiting for $WIDGET_URL to serve $EXPECTED_WIDGET_SHA256..." + for i in $(seq 1 30); do + ACTUAL_SHA="$(curl -sSf "$WIDGET_URL" | shasum -a 256 | awk '{print $1}')" + if [ "$ACTUAL_SHA" = "$EXPECTED_WIDGET_SHA256" ]; then + echo "Preview widget asset matched after $((i * 5))s" + exit 0 + fi + echo "Attempt $i/30 served $ACTUAL_SHA; waiting 5s..." + sleep 5 + done + echo "Preview widget did not serve expected asset $EXPECTED_WIDGET_SHA256" + exit 1 + - name: Run live E2E tests run: npx playwright test --project=chromium-live --workers=1 env: diff --git a/README.md b/README.md index d7775d4..9bfa6c2 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ That's it! Users can now click the bug button to submit feedback as GitHub Issue > **Important:** Do not add `async` or `defer` to the script tag — the widget needs synchronous loading to read its configuration. -> **CSP note:** If your site uses a Content Security Policy, add `https://cdn.jsdelivr.net` to your `script-src` directive to enable screenshot capture. +> **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. > **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. @@ -39,7 +39,7 @@ That's it! Users can now click the bug button to submit feedback as GitHub Issue ## Features -- 🔒 **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. +- 🔒 **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. ## Widget Options @@ -75,7 +75,7 @@ User clicks bug button → Widget captures screenshot → Worker authenticates v ``` 1. **Widget** loads in a Shadow DOM (isolated from your page styles) -2. **Screenshot** captured client-side using html2canvas +2. **Screenshot** captured client-side using html-to-image 3. **Worker** (Cloudflare) exchanges GitHub App credentials for an installation token 4. **GitHub API** creates the issue with the screenshot stored in `.bugdrop/` on a dedicated `bugdrop-screenshots` branch (auto-created on first use) diff --git a/docs/website/configuration.mdx b/docs/website/configuration.mdx index 7c8244b..524cad9 100644 --- a/docs/website/configuration.mdx +++ b/docs/website/configuration.mdx @@ -218,7 +218,27 @@ Control how screenshots are collected during the feedback flow. - **`auto`** -- Automatically captures a full-page screenshot after the form is submitted, without showing the manual screenshot picker or redaction step. - **`required`** -- Requires a screenshot before submission. Users can choose full page, element, or area, then annotate or redact before submitting. -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. +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. + +### Developer-configured screenshot masking + +BugDrop can visually mask fields that your app marks as private before a screenshot is uploaded: + +```html + +
Private account details
+``` + +Supported attributes: + +- `data-bugdrop-redact` +- `data-bd-redact` +- `data-bugdrop-redacted` +- `data-bugdrop-mask` + +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. + +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. ```html diff --git a/docs/website/faq.mdx b/docs/website/faq.mdx index 014b622..94ae350 100644 --- a/docs/website/faq.mdx +++ b/docs/website/faq.mdx @@ -42,9 +42,9 @@ Since BugDrop uses Shadow DOM for isolation, it does not conflict with any frame ### How does BugDrop handle screenshots? -BugDrop uses [html2canvas](https://html2canvas.hertzen.com/) to capture screenshots entirely on the client side. When a user clicks the screenshot button: +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: -1. html2canvas renders the current page to an HTML Canvas element in the user's browser +1. html-to-image renders the current page to an HTML Canvas element in the user's browser 2. In optional and required manual screenshot flows, the user can annotate the screenshot and cover sensitive regions with opaque blocks 3. The canvas is converted to a PNG image 4. The image is sent to the BugDrop API along with the form submission @@ -198,7 +198,7 @@ Check the following: 1. **Verify the script tag** -- Make sure it does not have `async` or `defer` attributes 2. **Check the console** -- Open your browser's developer console and look for `[BugDrop]` messages or errors -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` +3. **Check CSP** -- If your site uses a Content Security Policy, make sure `https://bugdrop.neonwatty.workers.dev` is allowed in `script-src` 4. **Check the repo** -- Ensure `data-repo` matches your GitHub repository path exactly (case-sensitive) 5. **Check app installation** -- Verify the BugDrop GitHub App is installed on the repository @@ -216,8 +216,8 @@ If the widget appears but submissions fail: If issues are created but without screenshots: 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. -2. **Check CSP** -- The html2canvas library is loaded from `cdn.jsdelivr.net`. Make sure it is allowed by your Content Security Policy. -3. **Cross-origin content** -- html2canvas cannot capture cross-origin iframes or images loaded without CORS headers. These elements may appear blank in the screenshot. +2. **Check CSP** -- Make sure your Content Security Policy allows the BugDrop widget script. +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. ### Can I use BugDrop on a static site? diff --git a/docs/website/installation.mdx b/docs/website/installation.mdx index 3cefc41..57f49a4 100644 --- a/docs/website/installation.mdx +++ b/docs/website/installation.mdx @@ -73,8 +73,9 @@ See the [Configuration](/docs/configuration) and [Styling](/docs/styling) docs f ## Protecting sensitive data -If your page renders customer data, billing details, or any other content you do not -want to appear in submitted screenshots, mark those elements with `data-bugdrop-mask`: +If your page renders customer data, billing details, or any other content you want +BugDrop to visually cover in supported screenshot modes, mark those elements with +`data-bugdrop-mask`: ```html
@@ -82,7 +83,7 @@ want to appear in submitted screenshots, mark those elements with `data-bugdrop-
``` -BugDrop covers each marked element with an opaque rectangle on the captured screenshot. +BugDrop covers each marked element with an opaque rectangle on supported captured screenshots. Password inputs and credit-card autocomplete fields are masked automatically. See [Screenshot masking](/security#screenshot-masking) on the Security page for details. @@ -107,13 +108,13 @@ The BugDrop script tag should **not** include the `async` or `defer` attributes. ### Content Security Policy (CSP) -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: +If your site uses a Content Security Policy, allow the BugDrop worker domain in your CSP directives: ``` -Content-Security-Policy: script-src 'self' https://bugdrop.neonwatty.workers.dev https://cdn.jsdelivr.net; +Content-Security-Policy: script-src 'self' https://bugdrop.neonwatty.workers.dev; ``` -If you have a strict CSP, make sure both the BugDrop worker domain and jsDelivr are included in your `script-src` directive. +If you have a strict CSP, make sure the BugDrop worker domain is included in your `script-src` directive. ### Branch Protection and the Screenshots Branch diff --git a/docs/website/security.mdx b/docs/website/security.mdx index 34692c8..6a37ca2 100644 --- a/docs/website/security.mdx +++ b/docs/website/security.mdx @@ -53,14 +53,14 @@ Treat screenshots as unauthenticated user-generated content. The hosted service ### Screenshot Format -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: +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: - The initial screenshot capture is rendered from what the user actually sees - No server-side rendering or page access is required - The screenshot is generated entirely in the user's browser before being sent to the API - Users can redact additional screenshot regions before submitting when using the manual screenshot flow -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. +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. 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. @@ -78,8 +78,7 @@ BugDrop is built with a privacy-first approach: ### Screenshot masking -You can mark sensitive elements so they never appear in submitted screenshots. 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 the `data-bugdrop-mask` attribute to any element you want covered: ```html @@ -94,6 +93,10 @@ When a user submits feedback, BugDrop paints an opaque rectangle over each tagge 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. +Masking is best-effort visual coverage, not a data-loss-prevention or security +boundary. Users should still review screenshots before submitting when the manual +screenshot flow is enabled. + **Inheritance.** When an ancestor has `data-bugdrop-mask`, the entire ancestor box is masked as a single rectangle. Descendants do not get individual rectangles — this 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. The only network requests BugDrop makes are: 1. Loading the widget script from Cloudflare Workers -2. Loading html2canvas from cdn.jsdelivr.net (for screenshot functionality) -3. Submitting the feedback form to the BugDrop Cloudflare Worker API +2. Submitting the feedback form to the BugDrop Cloudflare Worker API 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. diff --git a/e2e/widget.live.spec.ts b/e2e/widget.live.spec.ts index 7df2e99..9562b9c 100644 --- a/e2e/widget.live.spec.ts +++ b/e2e/widget.live.spec.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { test, expect, type Locator, type Page } from '@playwright/test'; /** @@ -13,6 +14,15 @@ import { test, expect, type Locator, type Page } from '@playwright/test'; // Add Vercel deployment protection bypass headers only to Vercel requests // (not globally, which would cause CORS preflight failures on cross-origin APIs) const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; +const expectedWidgetOrigin = + process.env.EXPECTED_WIDGET_ORIGIN || + (process.env.LIVE_TARGET === 'preview' + ? 'https://bugdrop-preview.neonwatty.workers.dev' + : process.env.LIVE_TARGET + ? 'https://bugdrop.neonwatty.workers.dev' + : undefined); +const expectedWidgetSha256 = process.env.EXPECTED_WIDGET_SHA256; + if (bypassSecret) { test.beforeEach(async ({ context }) => { await context.route('**/*.vercel.app/**', async route => { @@ -25,27 +35,8 @@ if (bypassSecret) { }); } -async function mockLiveScreenshotCapture(page: Page) { - await page.addInitScript(`window.__bugdropMockToPng = function(el, opts) { - window.__captureOpts = opts; - var canvas = document.createElement('canvas'); - var ctx = canvas.getContext('2d'); - canvas.width = 600; - canvas.height = 300; - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#111827'; - ctx.font = '600 18px Arial'; - ctx.fillText('Live preview undo canvas', 24, 38); - ctx.fillStyle = '#e5e7eb'; - ctx.fillRect(36, 82, 210, 128); - ctx.fillRect(354, 82, 210, 128); - ctx.fillStyle = '#6b7280'; - ctx.font = '14px Arial'; - ctx.fillText('First annotation area', 58, 150); - ctx.fillText('Latest annotation area', 374, 150); - return Promise.resolve(canvas.toDataURL('image/png')); - };`); +function sha256(buffer: Buffer): string { + return createHash('sha256').update(buffer).digest('hex'); } async function mockInstalledRepo(page: Page) { @@ -254,6 +245,15 @@ async function dragOnCanvas( await page.mouse.up(); } +async function expectUsableCanvas(canvas: Locator) { + await expect + .poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width)) + .toBeGreaterThan(0); + await expect + .poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).height)) + .toBeGreaterThan(0); +} + test.describe('Widget Loading (Live)', () => { test('widget loads and renders on cross-origin site', async ({ page }) => { const errors: string[] = []; @@ -285,17 +285,28 @@ test.describe('Widget Loading (Live)', () => { expect(unexpectedErrors).toHaveLength(0); }); - test('widget.js is served from the preview worker', async ({ request }) => { - const headers: Record = {}; - if (bypassSecret) { - headers['x-vercel-protection-bypass'] = bypassSecret; + test('venue loads the expected deployed widget asset', async ({ page, request }) => { + await page.goto(process.env.LIVE_TARGET === 'preview' ? '/' : '/test/'); + + const widgetSrc = await page.evaluate(() => { + return ( + Array.from(document.scripts) + .map(script => script.src) + .find(src => src.includes('/widget.js')) || '' + ); + }); + + if (expectedWidgetOrigin) { + expect(widgetSrc).toContain(`${expectedWidgetOrigin}/widget.js`); } - const response = await request.get('/', { headers }); + + const response = await request.get(widgetSrc); expect(response.ok()).toBeTruthy(); - const html = await response.text(); - // The page should contain a script tag pointing to the bugdrop widget - expect(html).toContain('widget.js'); + if (expectedWidgetSha256) { + const body = await response.body(); + expect(sha256(body)).toBe(expectedWidgetSha256); + } }); }); @@ -375,9 +386,9 @@ test.describe('Cross-Origin API (Live)', () => { // At least one API call should have been made expect(apiCalls.length).toBeGreaterThan(0); - // All API calls should go to the workers.dev domain (not the Vercel domain) + // All API calls should go to the expected worker origin (not the Vercel domain) for (const url of apiCalls) { - expect(url).toContain('workers.dev'); + expect(url).toContain(expectedWidgetOrigin || 'workers.dev'); } }); @@ -529,7 +540,6 @@ test.describe('Screenshot Capture (Live)', () => { }); test('annotation undo works on the deployed preview widget', async ({ page }) => { - await mockLiveScreenshotCapture(page); const host = await openScreenshotOptions(page, 'Live preview annotation undo'); const captureBtn = host.locator('css=[data-action="capture"]'); @@ -539,26 +549,29 @@ test.describe('Screenshot Capture (Live)', () => { const canvas = host.locator('css=#annotation-canvas canvas'); await expect(host.locator('css=.bd-modal--annotator')).toBeVisible({ timeout: 10_000 }); await expect(canvas).toBeVisible({ timeout: 10_000 }); - await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width)).toBe(600); - - await dragOnCanvas(page, canvas, { x: 0.18, y: 0.28 }, { x: 0.42, y: 0.68 }); - await dragOnCanvas(page, canvas, { x: 0.58, y: 0.28 }, { x: 0.82, y: 0.68 }); + await expectUsableCanvas(canvas); const firstRegion = { left: 0.1, top: 0.18, right: 0.48, bottom: 0.78 }; const latestRegion = { left: 0.52, top: 0.18, right: 0.9, bottom: 0.78 }; + const firstBaseline = await countRedPixelsInRegion(canvas, firstRegion); + const latestBaseline = await countRedPixelsInRegion(canvas, latestRegion); + + await dragOnCanvas(page, canvas, { x: 0.18, y: 0.28 }, { x: 0.42, y: 0.68 }); + await dragOnCanvas(page, canvas, { x: 0.58, y: 0.28 }, { x: 0.82, y: 0.68 }); - expect(await countRedPixelsInRegion(canvas, firstRegion)).toBeGreaterThan(20); - expect(await countRedPixelsInRegion(canvas, latestRegion)).toBeGreaterThan(20); + expect(await countRedPixelsInRegion(canvas, firstRegion)).toBeGreaterThan(firstBaseline + 20); + expect(await countRedPixelsInRegion(canvas, latestRegion)).toBeGreaterThan(latestBaseline + 20); await host.locator('css=[data-action="undo"]').click(); - expect(await countRedPixelsInRegion(canvas, firstRegion)).toBeGreaterThan(20); - expect(await countRedPixelsInRegion(canvas, latestRegion)).toBeLessThan(5); + expect(await countRedPixelsInRegion(canvas, firstRegion)).toBeGreaterThan(firstBaseline + 20); + await expect + .poll(() => countRedPixelsInRegion(canvas, latestRegion)) + .toBeLessThanOrEqual(latestBaseline + 10); }); test('redaction works on the deployed preview widget', async ({ page }) => { const payloads = await trackLiveFeedbackPayloads(page); - await mockLiveScreenshotCapture(page); const host = await openScreenshotOptions(page, 'Live preview redaction'); const captureBtn = host.locator('css=[data-action="capture"]'); @@ -568,7 +581,7 @@ test.describe('Screenshot Capture (Live)', () => { const canvas = host.locator('css=#annotation-canvas canvas'); await expect(host.locator('css=.bd-modal--annotator')).toBeVisible({ timeout: 10_000 }); await expect(canvas).toBeVisible({ timeout: 10_000 }); - await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width)).toBe(600); + await expectUsableCanvas(canvas); await host.locator('css=[data-tool="redact"]').click(); await dragOnCanvas(page, canvas, { x: 0.18, y: 0.28 }, { x: 0.42, y: 0.68 }); diff --git a/e2e/widget.spec.ts b/e2e/widget.spec.ts index 6a61b1d..92c8ee2 100644 --- a/e2e/widget.spec.ts +++ b/e2e/widget.spec.ts @@ -2202,6 +2202,20 @@ test.describe('Screenshot Crash Prevention (#67)', () => { }`; } + function redactionAwarePng() { + return `function(el, opts) { + window.__captureOpts = opts; + var canvas = document.createElement('canvas'); + var pixelRatio = opts && opts.pixelRatio ? opts.pixelRatio : 1; + canvas.width = Math.max(1, Math.ceil((opts && opts.width ? opts.width : document.documentElement.scrollWidth) * pixelRatio)); + canvas.height = Math.max(1, Math.ceil((opts && opts.height ? opts.height : document.documentElement.scrollHeight) * pixelRatio)); + var ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + return Promise.resolve(canvas.toDataURL('image/png')); + }`; + } + function mockGetDisplayMedia(page: Page, body: string) { return page.addInitScript(` Object.defineProperty(navigator, 'mediaDevices', { @@ -2883,7 +2897,17 @@ test.describe('Screenshot Crash Prevention (#67)', () => { configurable: true, }); }); - await page.goto('/test/complex-dom.html?nodes=12000'); + await page.goto('/test/redaction.html'); + await page.evaluate(() => { + const root = document.createElement('div'); + root.id = 'complexity-padding'; + for (let i = 0; i < 12000; i++) { + const node = document.createElement('span'); + node.textContent = `Item ${i}`; + root.appendChild(node); + } + document.body.appendChild(root); + }); const nodeCount = await page.evaluate(() => document.body.querySelectorAll('*').length); expect(nodeCount).toBeGreaterThanOrEqual(10000); @@ -2949,11 +2973,20 @@ test.describe('Screenshot Crash Prevention (#67)', () => { await expect(host.locator('css=[data-action="capture"]')).not.toBeAttached(); await expect(host.locator('css=[data-action="area"]')).not.toBeAttached(); await expect(host.locator('css=p >> text=visible viewport')).toBeVisible(); + await expect(host.locator('css=.bd-redaction-note')).toContainText( + 'Browser viewport capture cannot apply automatic private-field masks' + ); await viewportBtn.click(); await expect(host.locator('css=.bd-modal--annotator')).toBeVisible({ timeout: 10000 }); await expect(host.locator('css=#annotation-canvas canvas')).toBeVisible(); + await expect(host.locator('css=.bd-redaction-note')).toContainText( + 'could not apply automatic private-field masks' + ); + await expect(host.locator('css=.bd-redaction-note')).not.toContainText( + 'private item was marked for redaction' + ); await expect .poll(() => page.evaluate( @@ -3262,6 +3295,58 @@ test.describe('Screenshot Crash Prevention (#67)', () => { await expect(host.locator('css=p >> text=too complex')).not.toBeAttached(); }); + test('communicates developer redactions for full-page screenshots', async ({ page }) => { + await mockHtmlToImage(page, redactionAwarePng()); + await page.goto('/test/redaction.html'); + + const host = await navigateToScreenshotOptions(page); + await expect(host.locator('css=.bd-redaction-note')).toContainText( + 'marked some fields for redaction' + ); + + await host.locator('css=[data-action="capture"]').click(); + + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 10000 }); + await expect(host.locator('css=.bd-redaction-note')).toContainText( + '1 private item was marked for redaction' + ); + await expect(page.locator('#redacted-test-input')).toHaveValue('sk_live_local_test_secret'); + }); + + test('communicates developer redactions for selected-area screenshots', async ({ page }) => { + await mockHtmlToImage(page, redactionAwarePng()); + await page.goto('/test/redaction.html'); + await page.locator('#redacted-test-input').scrollIntoViewIfNeeded(); + + const host = await navigateToScreenshotOptions(page); + await host.locator('css=[data-action="area"]').click(); + await expect(page.locator('#bugdrop-area-picker-overlay')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('#bugdrop-area-picker-tooltip')).toContainText( + 'Marked private fields may be masked if included' + ); + + const box = await page.locator('#redacted-test-input').boundingBox(); + expect(box).toBeTruthy(); + + await page.mouse.move(box!.x - 8, box!.y - 8); + await page.mouse.down(); + await page.mouse.move(box!.x + box!.width + 8, box!.y + box!.height + 8); + await page.mouse.up(); + + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 10000 }); + await expect(host.locator('css=.bd-redaction-note')).toContainText( + '1 private item was marked for redaction' + ); + + const captureOpts = await page.evaluate( + () => + (window as Window & { __captureOpts?: { width?: number; height?: number } }).__captureOpts + ); + expect(captureOpts?.width).toBeGreaterThan(100); + expect(captureOpts?.height).toBeGreaterThan(40); + await expect(page.locator('#redacted-test-input')).toHaveValue('sk_live_local_test_secret'); + }); + test('annotation modal gives issue #117 style previews enough readable width', async ({ page, }) => { @@ -3833,6 +3918,22 @@ test.describe('Screenshot Mode Configuration', () => { ).toBe(1); }); + test('auto mode warns users about automatic full-page screenshots', async ({ page }) => { + await setupInstalledApp(page); + await mockSuccessfulCapture(page); + await setupSuccessfulSubmit(page); + + await page.goto('/test/redaction.html?screenshot=auto'); + const host = await openForm(page); + + await expect(host.locator('css=form')).toContainText( + 'This site will attach a full-page screenshot when you submit' + ); + await expect(host.locator('css=form')).toContainText( + 'unmarked sensitive information can still be included' + ); + }); + test('auto mode skips full-page capture on very complex pages', async ({ page }) => { await setupInstalledApp(page); await mockSuccessfulCapture(page); @@ -4059,6 +4160,19 @@ test.describe('Screenshot Masking', () => { expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); }); + test('masks elements tagged with data-bugdrop-redact', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/redaction.html' + ); + + const rect = await docRectOf(page, '#redacted-test-input'); + 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('does not mask unrelated elements', async ({ page }) => { const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( page, diff --git a/playwright.config.ts b/playwright.config.ts index 849af78..3f4418e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ webServer: process.env.LIVE_TARGET ? undefined : { - command: 'npm run dev', + command: 'BUGDROP_TEST_HOOKS=1 npm run build:widget && npm run dev', url: 'http://localhost:8787', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, diff --git a/public/test/redaction.html b/public/test/redaction.html new file mode 100644 index 0000000..1d56ded --- /dev/null +++ b/public/test/redaction.html @@ -0,0 +1,119 @@ + + + + + + BugDrop Redaction Fixture + + + +
+

Redaction Fixture

+

Fixture used by Playwright to verify BugDrop screenshot redaction behavior.

+ +
+ + + + + +
+
+ + + + + diff --git a/scripts/build-widget.js b/scripts/build-widget.js index f91f5fd..021db48 100644 --- a/scripts/build-widget.js +++ b/scripts/build-widget.js @@ -32,15 +32,24 @@ const [major, minor, patch] = version.split('.'); console.log(`Building widget version ${version}...`); +const enableTestHooks = process.env.BUGDROP_TEST_HOOKS === '1'; + // Build the main widget bundle execSync( - `npx esbuild src/widget/index.ts --bundle --minify --format=iife --define:__BUGDROP_VERSION__='"${version}"' --outfile=public/widget.js`, + `npx esbuild src/widget/index.ts --bundle --minify --format=iife --define:__BUGDROP_VERSION__='"${version}"' --define:__BUGDROP_ENABLE_TEST_HOOKS__=${enableTestHooks ? 'true' : 'false'} --outfile=public/widget.js`, { cwd: rootDir, stdio: 'inherit' } ); // Create versioned copies const widgetPath = join(publicDir, 'widget.js'); +if (!enableTestHooks) { + const widget = readFileSync(widgetPath, 'utf-8'); + if (widget.includes('__bugdropMockToPng')) { + throw new Error('Production widget build unexpectedly contains test screenshot hook'); + } +} + // widget.v{major}.js (e.g., widget.v1.js) const majorVersionPath = join(publicDir, `widget.v${major}.js`); copyFileSync(widgetPath, majorVersionPath); diff --git a/src/widget/area-picker.ts b/src/widget/area-picker.ts index 9c028e5..a8c1280 100644 --- a/src/widget/area-picker.ts +++ b/src/widget/area-picker.ts @@ -3,16 +3,25 @@ import { resolvePickerStyle } from './picker'; const MIN_SELECTION_SIZE = 10; const AREA_PICKER_INSTRUCTION = 'Draw a selection around the area to capture (ESC to cancel)'; +const AREA_PICKER_REDACTION_INSTRUCTION = + 'Draw a selection around the area to capture. Marked private fields may be masked if included. (ESC to cancel)'; -export function createAreaPicker(style?: PickerStyle): Promise { +export function createAreaPicker( + style?: PickerStyle, + opts?: { redactionsAvailable?: boolean } +): Promise { return new Promise(resolve => { setTimeout(() => { - startAreaPicker(resolve, style); + startAreaPicker(resolve, style, opts); }, 50); }); } -function startAreaPicker(resolve: (rect: DOMRect | null) => void, style?: PickerStyle): void { +function startAreaPicker( + resolve: (rect: DOMRect | null) => void, + style?: PickerStyle, + opts?: { redactionsAvailable?: boolean } +): void { const { accent, fontFamily, radius, bw, tooltipBg, tooltipText, tooltipBorder } = resolvePickerStyle(style); @@ -65,7 +74,9 @@ function startAreaPicker(resolve: (rect: DOMRect | null) => void, style?: Picker border: ${bw}px solid ${tooltipBorder}; pointer-events: none; `; - tooltip.textContent = AREA_PICKER_INSTRUCTION; + tooltip.textContent = opts?.redactionsAvailable + ? AREA_PICKER_REDACTION_INSTRUCTION + : AREA_PICKER_INSTRUCTION; document.body.appendChild(tooltip); let startX = 0; diff --git a/src/widget/index.ts b/src/widget/index.ts index 9fb4f64..b8a336d 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -1,11 +1,11 @@ import { beginViewportCapture, canCaptureViewportNatively, + captureAreaScreenshot, captureScreenshot, - cropScreenshot, FULL_PAGE_DISABLE_THRESHOLD, getDomNodeCount, - getPixelRatio, + getRedactionCount, isFullPageDisabled, } from './screenshot'; import { createElementPicker } from './picker'; @@ -762,6 +762,8 @@ async function openFeedbackFlow( let screenshot: string | null = null; let elementSelector: string | null = null; + let redactionCount = 0; + let redactionUnavailable = false; let returnToForm = false; // Step 3: Screenshot flow (if configured/user opted in) @@ -772,6 +774,7 @@ async function openFeedbackFlow( returnToForm = true; } else { screenshot = result; + redactionCount = screenshot ? getRedactionCount() : 0; } } } else if (formResult.includeScreenshot) { @@ -779,6 +782,8 @@ async function openFeedbackFlow( while (true) { screenshot = null; elementSelector = null; + redactionCount = 0; + redactionUnavailable = false; const screenshotChoice = await showScreenshotOptions(root, { allowSkip: !screenshotRequired, @@ -819,6 +824,8 @@ async function openFeedbackFlow( } if (!result && !screenshotRequired) rememberComplexScreenshotSkip(config, formResult); screenshot = result; + redactionUnavailable = Boolean(screenshot); + redactionCount = 0; } else if (screenshotChoice === 'capture') { const result = await captureWithLoading(root, undefined, config.screenshotScale, { allowSkip: !screenshotRequired, @@ -829,6 +836,7 @@ async function openFeedbackFlow( } if (!result) rememberComplexScreenshotSkip(config, formResult); screenshot = result; + redactionCount = screenshot ? getRedactionCount() : 0; } else if (screenshotChoice === 'element') { const element = await createElementPicker(pickerStyle); if (element) { @@ -841,28 +849,32 @@ async function openFeedbackFlow( } if (!result) rememberComplexScreenshotSkip(config, formResult); screenshot = result; + redactionCount = screenshot ? getRedactionCount(element) : 0; elementSelector = getElementSelector(element); } } else if (screenshotChoice === 'area') { - const rect = await createAreaPicker(pickerStyle); + const rect = await createAreaPicker(pickerStyle, { + redactionsAvailable: getRedactionCount() > 0, + }); if (rect) { - const pixelRatio = getPixelRatio(true, config.screenshotScale); - const fullPage = await captureWithLoading(root, undefined, config.screenshotScale, { + const result = await captureAreaWithLoading(root, rect, config.screenshotScale, { allowSkip: !screenshotRequired, }); - if (fullPage === 'cancel') { + if (result === 'cancel') { returnToForm = true; break; } - if (fullPage) { - screenshot = await cropScreenshot(fullPage, rect, pixelRatio); - } + if (!result) rememberComplexScreenshotSkip(config, formResult); + screenshot = result; + redactionCount = screenshot ? getRedactionCount(undefined, rect) : 0; } } // Step 4: Annotate (if screenshot exists) if (screenshot) { - const result = await showAnnotationStep(root, screenshot); + const result = await showAnnotationStep(root, screenshot, redactionCount, { + redactionUnavailable, + }); if (result === 'retake') continue; if (result === 'cancel') { returnToForm = true; @@ -908,6 +920,20 @@ async function captureWithLoading( ); } +async function captureAreaWithLoading( + root: HTMLElement, + rect: DOMRect, + screenshotScale?: number, + opts?: { allowSkip?: boolean } +): Promise { + return capturePromiseWithLoading( + root, + captureAreaScreenshot(rect, screenshotScale), + () => captureAreaScreenshot(rect, screenshotScale), + opts + ); +} + async function capturePromiseWithLoading( root: HTMLElement, capturePromise: Promise, @@ -1228,9 +1254,13 @@ function getScreenshotFormControl( initialValues?: FeedbackFormResult | null ): string { if (config.screenshotMode === 'auto') { + const redactionNote = + getRedactionCount() > 0 + ? ' Some fields this site marked private may be visually masked on supported pages, but unmarked sensitive information can still be included.' + : ''; return `

- 📸 A screenshot will be attached automatically without preview or manual redaction. + This site will attach a full-page screenshot when you submit without showing a preview. Review your page for sensitive information before sending.${redactionNote}

`; } @@ -1280,6 +1310,12 @@ function showScreenshotOptions( const fullPageDisabled = isFullPageDisabled(); const nativeViewportAvailable = fullPageDisabled && canCaptureViewportNatively(); const allowSkip = opts?.allowSkip !== false; + const redactionNote = + nativeViewportAvailable && fullPageDisabled + ? '

Browser viewport capture cannot apply automatic private-field masks. Select Element to preserve automatic masking, or review and cover sensitive areas before sending.

' + : getRedactionCount() > 0 + ? '

This site marked some fields for redaction. Review the screenshot before sending.

' + : ''; return new Promise(resolve => { const complexNote = fullPageDisabled @@ -1297,6 +1333,7 @@ function showScreenshotOptions( `

Choose what to capture:

${complexNote} + ${redactionNote}
${primaryCaptureButton} ${fullPageDisabled ? '' : ''} @@ -1348,15 +1385,23 @@ function showScreenshotOptions( function showAnnotationStep( root: HTMLElement, - screenshot: string + screenshot: string, + redactionCount = 0, + opts?: { redactionUnavailable?: boolean } ): Promise { return new Promise(resolve => { + const redactionNote = opts?.redactionUnavailable + ? `

This browser viewport capture could not apply automatic private-field masks. Review and cover any sensitive areas before sending.

` + : redactionCount > 0 + ? `

${redactionCount} private ${redactionCount === 1 ? 'item was' : 'items were'} marked for redaction in this screenshot. Review before sending.

` + : ''; const modal = createModal( root, 'Review Screenshot', ` + ${redactionNote}

- Cover sensitive areas before submitting. Redactions are baked into the uploaded image. + Check that no sensitive information is visible before sending. Cover sensitive areas before submitting. Redactions are baked into the uploaded image.

diff --git a/src/widget/mask.ts b/src/widget/mask.ts index c6d22f0..d74b4c6 100644 --- a/src/widget/mask.ts +++ b/src/widget/mask.ts @@ -5,7 +5,8 @@ interface MaskRect { h: number; } -const EXPLICIT_SELECTOR = '[data-bugdrop-mask]'; +const EXPLICIT_SELECTOR = + '[data-bugdrop-mask], [data-bugdrop-redact], [data-bd-redact], [data-bugdrop-redacted]'; const DEFAULT_SELECTOR = 'input[type="password"], input[autocomplete*="cc-number"], input[autocomplete*="cc-csc"], input[autocomplete*="cc-exp"]'; @@ -36,6 +37,21 @@ export function collectMaskRects(root: Element): MaskRect[] { return rects; } +export function countMaskRects(root: Element = document.body, area?: DOMRect): number { + const rects = collectMaskRects(root); + if (!area) return rects.length; + return rects.filter(rect => intersects(rect, area)).length; +} + +function intersects(rect: MaskRect, area: DOMRect): boolean { + return ( + rect.x < area.x + area.width && + rect.x + rect.w > area.x && + rect.y < area.y + area.height && + rect.y + rect.h > area.y + ); +} + function walk(node: Element, rects: MaskRect[]): void { for (const child of Array.from(node.children)) { if (shouldMask(child)) { @@ -59,10 +75,10 @@ export function translateMaskRect( const rawW = rect.w * pixelRatio; const rawH = rect.h * pixelRatio; - const x = Math.max(0, rawX); - const y = Math.max(0, rawY); - const right = Math.min(canvasWidth, rawX + rawW); - const bottom = Math.min(canvasHeight, rawY + rawH); + const x = Math.max(0, Math.floor(rawX) - 1); + const y = Math.max(0, Math.floor(rawY) - 1); + const right = Math.min(canvasWidth, Math.ceil(rawX + rawW) + 1); + const bottom = Math.min(canvasHeight, Math.ceil(rawY + rawH) + 1); return { x, diff --git a/src/widget/screenshot.ts b/src/widget/screenshot.ts index 9aca83a..83c67d2 100644 --- a/src/widget/screenshot.ts +++ b/src/widget/screenshot.ts @@ -1,5 +1,8 @@ import * as htmlToImage from 'html-to-image'; -import { collectMaskRects, applyMaskToImage } from './mask'; +import type { Options as HtmlToImageOptions } from 'html-to-image/lib/types'; +import { applyMaskToImage, collectMaskRects, countMaskRects } from './mask'; + +declare const __BUGDROP_ENABLE_TEST_HOOKS__: boolean; const CAPTURE_TIMEOUT_MS = 15_000; const DOM_COMPLEXITY_THRESHOLD = 3_000; @@ -18,6 +21,7 @@ type VideoElementWithFrameCallback = HTMLVideoElement & { declare global { interface Window { __bugdropMockViewportCapture?: () => Promise; + __bugdropMockToPng?: typeof htmlToImage.toPng; } } @@ -190,11 +194,8 @@ export async function captureScreenshot( const pixelRatio = getPixelRatio(isFullPage, screenshotScale); - const toPng = - (window as unknown as { __bugdropMockToPng?: typeof htmlToImage.toPng }).__bugdropMockToPng ?? - htmlToImage.toPng; - - const opts = { + const toPng = getToPng(); + const opts: HtmlToImageOptions = { cacheBust: false, imagePlaceholder: TRANSPARENT_IMAGE_PLACEHOLDER, pixelRatio, @@ -213,6 +214,48 @@ export async function captureScreenshot( return applyMaskToImage(dataUrl, rects, pixelRatio, originOffset); } +export async function captureAreaScreenshot( + rect: DOMRect, + screenshotScale?: number +): Promise { + const pixelRatio = getPixelRatio(true, screenshotScale); + const toPng = getToPng(); + const opts: HtmlToImageOptions = { + cacheBust: false, + imagePlaceholder: TRANSPARENT_IMAGE_PLACEHOLDER, + pixelRatio, + width: rect.width, + height: rect.height, + style: { + transform: `translate(${-rect.x}px, ${-rect.y}px)`, + transformOrigin: 'top left', + width: `${document.documentElement.scrollWidth}px`, + height: `${document.documentElement.scrollHeight}px`, + }, + filter: (node: HTMLElement) => node.id !== 'bugdrop-host', + }; + + const dataUrl = await withCaptureTimeout(toPng(document.body, opts)); + return applyMaskToImage(dataUrl, collectMaskRects(document.body), pixelRatio, { + x: rect.x, + y: rect.y, + }); +} + +export function getRedactionCount(element?: Element, rect?: DOMRect): number { + return countMaskRects(element ?? document.body, rect); +} + +function getToPng(): typeof htmlToImage.toPng { + if ( + (typeof __BUGDROP_ENABLE_TEST_HOOKS__ === 'undefined' || __BUGDROP_ENABLE_TEST_HOOKS__) && + window.__bugdropMockToPng + ) { + return window.__bugdropMockToPng; + } + return htmlToImage.toPng; +} + export async function cropScreenshot( imageDataUrl: string, rect: DOMRect, diff --git a/test/mask.test.ts b/test/mask.test.ts index b96b0a1..9803f5e 100644 --- a/test/mask.test.ts +++ b/test/mask.test.ts @@ -222,28 +222,28 @@ describe('translateMaskRect', () => { it('scales a rect by pixelRatio with no origin offset', () => { expect( translateMaskRect({ x: 10, y: 20, w: 100, h: 50 }, 2, { x: 0, y: 0 }, 1000, 1000) - ).toEqual({ x: 20, y: 40, w: 200, h: 100 }); + ).toEqual({ x: 19, y: 39, w: 202, h: 102 }); }); it('subtracts originOffset before scaling', () => { expect( translateMaskRect({ x: 110, y: 220, w: 100, h: 50 }, 2, { x: 100, y: 200 }, 1000, 1000) - ).toEqual({ x: 20, y: 40, w: 200, h: 100 }); + ).toEqual({ x: 19, y: 39, w: 202, h: 102 }); }); it('clips a rect that overflows the canvas on the right and bottom', () => { expect(translateMaskRect({ x: 90, y: 90, w: 30, h: 30 }, 1, { x: 0, y: 0 }, 100, 100)).toEqual({ - x: 90, - y: 90, - w: 10, - h: 10, + x: 89, + y: 89, + w: 11, + h: 11, }); }); it('clips a rect that starts to the left and above the canvas', () => { expect( translateMaskRect({ x: -10, y: -20, w: 30, h: 50 }, 1, { x: 0, y: 0 }, 100, 100) - ).toEqual({ x: 0, y: 0, w: 20, h: 30 }); + ).toEqual({ x: 0, y: 0, w: 21, h: 31 }); }); it('returns a non-positive size when fully outside the canvas', () => { @@ -298,13 +298,12 @@ describe('applyMaskToImage', () => { it('paints rects at translated coords (scales by pixelRatio)', async () => { // FakeImage: naturalWidth=200, naturalHeight=100 → canvas is 200×100 // rect {x:5,y:5,w:20,h:10}, pixelRatio=2, originOffset={x:0,y:0} - // translateMaskRect: rawX=10, rawY=10, rawW=40, rawH=20 - // right=min(200,10+40)=50, bottom=min(100,10+20)=30 → no clipping → fillRect(10,10,40,20) + // translateMaskRect over-covers by one pixel on each edge to avoid anti-aliased slivers. await applyMaskToImage('data:image/png;base64,test', [{ x: 5, y: 5, w: 20, h: 10 }], 2, { x: 0, y: 0, }); - expect(ctx.fillRect).toHaveBeenCalledWith(10, 10, 40, 20); + expect(ctx.fillRect).toHaveBeenCalledWith(9, 9, 42, 22); }); it('returns the input dataUrl unchanged when rects is empty', async () => { @@ -318,13 +317,12 @@ describe('applyMaskToImage', () => { it('subtracts originOffset before scaling', async () => { // FakeImage: naturalWidth=200, naturalHeight=100 → canvas is 200×100 // rect {x:105,y:205,w:20,h:10}, pixelRatio=2, originOffset={x:100,y:200} - // translateMaskRect: rawX=(105-100)*2=10, rawY=(205-200)*2=10, rawW=40, rawH=20 - // right=min(200,10+40)=50, bottom=min(100,10+20)=30 → no clipping → fillRect(10,10,40,20) + // translateMaskRect over-covers by one pixel on each edge to avoid anti-aliased slivers. await applyMaskToImage('data:image/png;base64,test', [{ x: 105, y: 205, w: 20, h: 10 }], 2, { x: 100, y: 200, }); - expect(ctx.fillRect).toHaveBeenCalledWith(10, 10, 40, 20); + expect(ctx.fillRect).toHaveBeenCalledWith(9, 9, 42, 22); }); it('skips a rect that translates to non-positive dimensions', async () => {