Problem
Screenshot evidence URLs are validated only as "some URL":
lib/schemas/submit.ts line 51: screenshots: z.array(z.string().url()).max(5)
app/api/posts/edit/[token]/route.ts: screenshotUrls: z.array(z.string().url()).max(5).optional()
Nothing checks that the URL points at our own R2 bucket. The upload flow (app/api/upload/presign/route.ts plus lib/r2/upload.ts) carefully restricts content type and size and returns a publicUrl built from getR2PublicBaseUrl(), but the submit and edit endpoints happily accept any https:// string in that field instead.
Why it matters
A submitter can point screenshots at any third party host. Those URLs are then rendered on public case pages, which means:
- arbitrary remote content is embedded under our domain, and it can be swapped after moderation approval, so what a moderator approved is not what visitors later see;
- every case page view leaks visitor IP and referrer to whatever host was supplied;
- it is a cheap vector for hosting content we did not screen.
The presign hardening is effectively bypassable because it is not the only way to get a URL into the field.
Suggested approach
- Add a shared validator, for example
isOwnedScreenshotUrl(url) in lib/utils/urls.ts, that requires the URL to start with getR2PublicBaseUrl() and to have a plausible object key shape (screenshots/<uuid>.<ext>).
- Use it in a
.refine() on both submitSchema.screenshots and the edit route's screenshotUrls.
- Decide and document the behavior for existing rows that violate the rule (probably: leave them, add a follow up cleanup script under
scripts/).
Done when
- Submitting or editing a case with an off site screenshot URL returns 400 with a clear message.
- Tests cover: valid R2 URL accepted, external URL rejected, malformed key rejected, empty array accepted.
If you want to take this on, comment on the issue to claim it and it will be assigned. Please keep to a maximum of 2 open claims per person at a time so other contributors get a chance.
Problem
Screenshot evidence URLs are validated only as "some URL":
lib/schemas/submit.tsline 51:screenshots: z.array(z.string().url()).max(5)app/api/posts/edit/[token]/route.ts:screenshotUrls: z.array(z.string().url()).max(5).optional()Nothing checks that the URL points at our own R2 bucket. The upload flow (
app/api/upload/presign/route.tspluslib/r2/upload.ts) carefully restricts content type and size and returns apublicUrlbuilt fromgetR2PublicBaseUrl(), but the submit and edit endpoints happily accept anyhttps://string in that field instead.Why it matters
A submitter can point
screenshotsat any third party host. Those URLs are then rendered on public case pages, which means:The presign hardening is effectively bypassable because it is not the only way to get a URL into the field.
Suggested approach
isOwnedScreenshotUrl(url)inlib/utils/urls.ts, that requires the URL to start withgetR2PublicBaseUrl()and to have a plausible object key shape (screenshots/<uuid>.<ext>)..refine()on bothsubmitSchema.screenshotsand the edit route'sscreenshotUrls.scripts/).Done when
If you want to take this on, comment on the issue to claim it and it will be assigned. Please keep to a maximum of 2 open claims per person at a time so other contributors get a chance.