Skip to content

Commit 092a4b8

Browse files
authored
Merge pull request #132 from mean-weasel/feat/screenshot-security-hardening
Harden screenshot upload validation and security docs
2 parents 63cc05e + ce2a943 commit 092a4b8

7 files changed

Lines changed: 158 additions & 24 deletions

File tree

PRIVACY.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,17 @@ When a user submits feedback through the BugDrop widget, the following data is c
2323

2424
## Where Data Goes
2525

26-
All submitted feedback is sent to the **GitHub API** and created as a GitHub Issue in the repository configured by the site owner. Screenshots are stored in the repository's `.bugdrop/` directory. The BugDrop Cloudflare Worker acts only as a pass-through to authenticate with GitHub — it does not store any submitted data.
26+
All submitted feedback is sent to the **GitHub API** and created as a GitHub Issue in the repository configured by the site owner. Screenshots are stored in the repository's `.bugdrop/` directory on the `bugdrop-screenshots` branch. The BugDrop Cloudflare Worker acts only as a pass-through to authenticate with GitHub — it does not store any submitted data.
27+
28+
Feedback and screenshots are unauthenticated user-generated content. The hosted service applies rate limits, size limits, and PNG screenshot validation, but it does not provide email-grade spam or malware filtering.
2729

2830
## Data Processing
2931

3032
BugDrop runs on **Cloudflare Workers**. Requests are processed in-memory and are not logged or persisted by the BugDrop service. Cloudflare's standard infrastructure policies apply to network-level processing.
3133

3234
## Self-Hosting
3335

34-
BugDrop is fully open source and self-hostable. If you run your own instance, you control all data processing. See [SELF_HOSTING.md](./SELF_HOSTING.md) for instructions.
36+
BugDrop is fully open source and self-hostable. If you run your own instance, you control all data processing and can add your own WAF, CAPTCHA, logging, retention, and content filtering controls. See [SELF_HOSTING.md](./SELF_HOSTING.md) for instructions.
3537

3638
## Contact
3739

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ That's it! Users can now click the bug button to submit feedback as GitHub Issue
3232
3333
> **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.
3434
35+
> **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.
36+
3537
## Widget Options
3638

3739
| Attribute | Values | Default |

TERMS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ BugDrop is a free, open-source feedback widget that creates GitHub Issues with s
1212
- You may use the hosted widget on any website where you have authority to add scripts.
1313
- You must have the GitHub App installed on a repository you own or administer.
1414
- Do not use the service to submit spam, abusive content, or illegal material via GitHub Issues.
15+
- The hosted service is not a spam, malware, or content moderation service. Feedback submissions are unauthenticated user-generated content.
1516

1617
## No Warranty
1718

@@ -21,6 +22,8 @@ THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED
2122

2223
The service enforces rate limits (10 requests per IP per 15 minutes, 50 per repository per hour) to prevent abuse and protect GitHub API quotas. Exceeding these limits will result in temporary throttling.
2324

25+
For stricter security or compliance requirements, self-host BugDrop and apply your own WAF, CAPTCHA, logging, retention, and content filtering controls.
26+
2427
## GitHub
2528

2629
BugDrop interacts with GitHub on your behalf via the GitHub App. Your use of GitHub is subject to [GitHub's Terms of Service](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service). BugDrop requests only the minimum permissions needed: Issues (read/write) and Contents (read/write) on repositories where you install it.

docs/website/installation.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ If you have a strict CSP, make sure both the BugDrop worker domain and jsDelivr
104104

105105
BugDrop stores screenshots in a dedicated branch called `bugdrop-screenshots` in your repository. This branch is created automatically when the first screenshot is uploaded.
106106

107+
Treat this branch as user-generated content storage. Exclude `bugdrop-screenshots` from CI/deploy workflows and keep privileged workflows limited to `main` or other trusted branches.
108+
107109
**If you use branch protection rules**, make sure the BugDrop GitHub App has permission to push to the `bugdrop-screenshots` branch. You can do this by either:
108110

109111
1. **Excluding the branch from protection rules** -- Add `bugdrop-screenshots` to the exclusion list in your branch protection settings

docs/website/security.mdx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ The BugDrop GitHub App requests the minimum permissions necessary to function:
1111
| **Issues** | Read & Write | Create bug reports, feature requests, and questions as GitHub Issues |
1212
| **Contents** | Read & Write | Store screenshots in the repository on the `bugdrop-screenshots` branch |
1313

14+
GitHub App `Contents` permissions are repository-scoped, not branch-scoped. BugDrop's implementation writes screenshots only to `.bugdrop/screenshots/` on the dedicated `bugdrop-screenshots` branch, but GitHub does not provide a narrower branch-only permission for this API.
15+
1416
BugDrop does **not** request access to:
1517

16-
- Your source code (contents access is limited to the `bugdrop-screenshots` branch)
1718
- Pull requests
1819
- Actions or workflows
1920
- Secrets or environment variables
@@ -48,6 +49,8 @@ Screenshots are stored as image files in a `.bugdrop/` directory on a dedicated
4849

4950
The screenshot branch is created automatically when the first screenshot is uploaded. No manual setup is required.
5051

52+
Treat screenshots as unauthenticated user-generated content. The hosted service enforces rate limits, size limits, and PNG payload validation, but it is not a spam or malware filtering product.
53+
5154
### Screenshot Format
5255

5356
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,6 +59,8 @@ Screenshots are captured client-side using [html2canvas](https://html2canvas.her
5659
- No server-side rendering or page access is required
5760
- The screenshot is generated entirely in the user's browser before being sent to the API
5861

62+
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.
63+
5964
## Privacy
6065

6166
BugDrop is built with a privacy-first approach:
@@ -132,8 +137,11 @@ If these limits are too restrictive for your use case, consider [self-hosting](/
132137

133138
1. **Review app permissions** -- Periodically check the BugDrop GitHub App's permissions in your GitHub settings
134139
2. **Monitor the screenshots branch** -- Occasionally review the `bugdrop-screenshots` branch for unexpected content
135-
3. **Use branch protection** -- Keep your main branch protected. BugDrop only needs write access to the `bugdrop-screenshots` branch
136-
4. **Set up CSP** -- If you use a Content Security Policy, explicitly whitelist the required domains rather than using broad wildcards
140+
3. **Exclude screenshot storage from CI** -- Do not run privileged CI/deploy workflows on `bugdrop-screenshots`; treat it as user-generated content storage, not application source
141+
4. **Use branch protection** -- Keep your main branch protected and limit deploy/build workflows to `main` or other trusted branches
142+
5. **Set up CSP** -- If you use a Content Security Policy, explicitly whitelist the required domains rather than using broad wildcards
143+
144+
The hosted service is intended for lightweight feedback collection. If your site is public, high-traffic, compliance-sensitive, or exposed to adversarial submissions, self-host BugDrop and place it behind your own WAF, CAPTCHA, logging, retention, and content filtering controls.
137145

138146
### For Self-Hosters
139147

@@ -142,7 +150,9 @@ If you run your own instance of BugDrop:
142150
1. **Rotate your GitHub App credentials** regularly
143151
2. **Set appropriate rate limits** for your expected traffic
144152
3. **Monitor your Cloudflare Worker logs** for unusual activity
145-
4. **Keep your instance updated** with the latest version
153+
4. **Add edge protections** such as WAF rules, CAPTCHA, bot detection, and allowlists as needed
154+
5. **Define retention/cleanup** for the `bugdrop-screenshots` branch
155+
6. **Keep your instance updated** with the latest version
146156

147157
## Reporting Security Issues
148158

src/routes/api.ts

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,13 @@ api.post('/feedback', async c => {
101101
);
102102
}
103103

104-
// Validate screenshot size
104+
// Validate screenshot payload. The browser widget emits PNG data URLs, but
105+
// callers can hit the API directly, so the server must not trust the prefix.
105106
const maxSizeMB = parseInt(c.env.MAX_SCREENSHOT_SIZE_MB || '5', 10);
106107
if (payload.screenshot) {
107-
const sizeBytes = (payload.screenshot.length * 3) / 4; // Base64 to bytes
108-
const sizeMB = sizeBytes / (1024 * 1024);
109-
if (sizeMB > maxSizeMB) {
110-
return c.json(
111-
{
112-
error: `Screenshot too large: ${sizeMB.toFixed(1)}MB exceeds ${maxSizeMB}MB limit`,
113-
},
114-
400
115-
);
108+
const validation = validateScreenshotDataUrl(payload.screenshot, maxSizeMB);
109+
if (!validation.valid) {
110+
return c.json({ error: validation.error }, 400);
116111
}
117112
}
118113

@@ -144,7 +139,7 @@ api.post('/feedback', async c => {
144139
// Upload screenshot as file and get URL
145140
let screenshotUrl: string | undefined;
146141
const imageData = payload.screenshot;
147-
if (imageData && imageData.startsWith('data:image/')) {
142+
if (imageData) {
148143
try {
149144
screenshotUrl = await uploadScreenshotAsAsset(token, owner, repo, imageData);
150145
} catch (error) {
@@ -190,6 +185,72 @@ api.post('/feedback', async c => {
190185
}
191186
});
192187

188+
type ScreenshotValidationResult = { valid: true } | { valid: false; error: string };
189+
190+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
191+
192+
function validateScreenshotDataUrl(dataUrl: string, maxSizeMB: number): ScreenshotValidationResult {
193+
const match = dataUrl.match(/^data:image\/png;base64,([A-Za-z0-9+/]+={0,2})$/);
194+
if (!match) {
195+
return {
196+
valid: false,
197+
error: 'Invalid screenshot format. Expected a PNG data URL.',
198+
};
199+
}
200+
201+
const base64 = match[1];
202+
if (!base64) {
203+
return {
204+
valid: false,
205+
error: 'Invalid screenshot format. Expected a PNG data URL.',
206+
};
207+
}
208+
209+
const estimatedSizeBytes =
210+
Math.floor((base64.length * 3) / 4) -
211+
(base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0);
212+
const estimatedSizeMB = estimatedSizeBytes / (1024 * 1024);
213+
if (estimatedSizeMB > maxSizeMB) {
214+
return {
215+
valid: false,
216+
error: `Screenshot too large: ${estimatedSizeMB.toFixed(1)}MB exceeds ${maxSizeMB}MB limit`,
217+
};
218+
}
219+
220+
let bytes: Uint8Array;
221+
try {
222+
bytes = base64ToBytes(base64);
223+
} catch {
224+
return {
225+
valid: false,
226+
error: 'Invalid screenshot format. Expected valid base64 PNG data.',
227+
};
228+
}
229+
230+
if (!hasPngSignature(bytes)) {
231+
return {
232+
valid: false,
233+
error: 'Invalid screenshot format. Expected PNG image data.',
234+
};
235+
}
236+
237+
return { valid: true };
238+
}
239+
240+
function base64ToBytes(base64: string): Uint8Array {
241+
const binary = atob(base64);
242+
const bytes = new Uint8Array(binary.length);
243+
for (let i = 0; i < binary.length; i++) {
244+
bytes[i] = binary.charCodeAt(i);
245+
}
246+
return bytes;
247+
}
248+
249+
function hasPngSignature(bytes: Uint8Array): boolean {
250+
if (bytes.length < PNG_SIGNATURE.length) return false;
251+
return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte);
252+
}
253+
193254
/**
194255
* Format the issue body with markdown
195256
*/

test/api.test.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const createApiRoutes = async () => {
2222
};
2323

2424
describe('API Routes', () => {
25+
const validPngDataUrl =
26+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
27+
2528
let app: Hono;
2629
const mockEnv: Env = {
2730
GITHUB_APP_ID: 'test-app-id',
@@ -274,8 +277,6 @@ describe('API Routes', () => {
274277

275278
it('should upload screenshot and include URL in issue body', async () => {
276279
mockGetInstallationToken.mockResolvedValue('test-token');
277-
const screenshotDataUrl =
278-
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
279280
const uploadedUrl =
280281
'https://raw.githubusercontent.com/testowner/testrepo/main/.feedback/screenshots/123456.png';
281282
mockUploadScreenshotAsAsset.mockResolvedValue(uploadedUrl);
@@ -286,7 +287,7 @@ describe('API Routes', () => {
286287

287288
const payloadWithScreenshot = {
288289
...validPayload,
289-
screenshot: screenshotDataUrl,
290+
screenshot: validPngDataUrl,
290291
};
291292

292293
const req = new Request('http://localhost/feedback', {
@@ -301,7 +302,7 @@ describe('API Routes', () => {
301302
'test-token',
302303
'testowner',
303304
'testrepo',
304-
screenshotDataUrl
305+
validPngDataUrl
305306
);
306307
expect(mockCreateIssue).toHaveBeenCalledWith(
307308
'test-token',
@@ -315,7 +316,6 @@ describe('API Routes', () => {
315316

316317
it('should use screenshot when provided (annotations handled client-side)', async () => {
317318
mockGetInstallationToken.mockResolvedValue('test-token');
318-
const screenshotDataUrl = 'data:image/png;base64,screenshot';
319319
const uploadedUrl =
320320
'https://raw.githubusercontent.com/testowner/testrepo/main/.feedback/screenshots/789.png';
321321
mockUploadScreenshotAsAsset.mockResolvedValue(uploadedUrl);
@@ -326,7 +326,7 @@ describe('API Routes', () => {
326326

327327
const payloadWithScreenshot = {
328328
...validPayload,
329-
screenshot: screenshotDataUrl,
329+
screenshot: validPngDataUrl,
330330
};
331331

332332
const req = new Request('http://localhost/feedback', {
@@ -340,7 +340,7 @@ describe('API Routes', () => {
340340
'test-token',
341341
'testowner',
342342
'testrepo',
343-
screenshotDataUrl
343+
validPngDataUrl
344344
);
345345
});
346346

@@ -382,6 +382,60 @@ describe('API Routes', () => {
382382
expect(data.error).toContain('exceeds 5MB limit');
383383
});
384384

385+
it('should reject SVG screenshots', async () => {
386+
const svgScreenshot =
387+
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==';
388+
389+
const req = new Request('http://localhost/feedback', {
390+
method: 'POST',
391+
headers: { 'Content-Type': 'application/json' },
392+
body: JSON.stringify({
393+
...validPayload,
394+
screenshot: svgScreenshot,
395+
}),
396+
});
397+
const res = await app.fetch(req, mockEnv);
398+
const data = await res.json();
399+
400+
expect(res.status).toBe(400);
401+
expect(data.error).toContain('Expected a PNG data URL');
402+
expect(mockUploadScreenshotAsAsset).not.toHaveBeenCalled();
403+
});
404+
405+
it('should reject invalid base64 screenshots', async () => {
406+
const req = new Request('http://localhost/feedback', {
407+
method: 'POST',
408+
headers: { 'Content-Type': 'application/json' },
409+
body: JSON.stringify({
410+
...validPayload,
411+
screenshot: 'data:image/png;base64,not valid base64',
412+
}),
413+
});
414+
const res = await app.fetch(req, mockEnv);
415+
const data = await res.json();
416+
417+
expect(res.status).toBe(400);
418+
expect(data.error).toContain('Expected a PNG data URL');
419+
expect(mockUploadScreenshotAsAsset).not.toHaveBeenCalled();
420+
});
421+
422+
it('should reject PNG data URLs with non-PNG bytes', async () => {
423+
const req = new Request('http://localhost/feedback', {
424+
method: 'POST',
425+
headers: { 'Content-Type': 'application/json' },
426+
body: JSON.stringify({
427+
...validPayload,
428+
screenshot: 'data:image/png;base64,SGVsbG8=',
429+
}),
430+
});
431+
const res = await app.fetch(req, mockEnv);
432+
const data = await res.json();
433+
434+
expect(res.status).toBe(400);
435+
expect(data.error).toContain('Expected PNG image data');
436+
expect(mockUploadScreenshotAsAsset).not.toHaveBeenCalled();
437+
});
438+
385439
it('should return 500 when GitHub API fails', async () => {
386440
mockGetInstallationToken.mockResolvedValue('test-token');
387441
mockCreateIssue.mockRejectedValue(new Error('GitHub API error'));

0 commit comments

Comments
 (0)