Skip to content

security: presigned URLs in UploadDto, honour the anon flag (BREAKING) - #9

Open
IbraTech04 wants to merge 1 commit into
minbar-featuresfrom
sec/unit2-presigned-upload-urls
Open

IbraTech04 wants to merge 1 commit into
minbar-featuresfrom
sec/unit2-presigned-upload-urls

Conversation

@IbraTech04

Copy link
Copy Markdown
Collaborator

The problem

Verified live on localhost:8080 as an ordinary ROLE_USER, GET /api/user/uploads returned:

{"uuid":"dbc118c8-…","fileUrl":"images/e5c6f37c-845d-4252-9a2f-16e5e68eee96",
 "thumbnailUrl":"thumbnails/e5c6f37c-…","uploadedBy":"f7b1d402-14bb-4c60-b7bb-d7bf54dc5d00",
 "approved":true,"anon":true}

Two defects in a single response.

1. The raw R2 object key is handed to the client. AdminUploadDto deliberately routes through R2StorageService.getSecureUrl(key, approved, isAdmin), which throws SecurityException when the content is not approved and the caller is not an admin. The user-facing UploadDto path skipped that security boundary entirely and returned the key verbatim. PosterService.java:100,181 serves publicUrl + "/" + objectKey unsigned, which confirms the bucket sits behind a public custom domain (media.lensbridge.tech) — so a leaked key is a direct, unauthenticated fetch of the media, bypassing the approval gate.

2. uploadedBy is serialized on anon: true uploads. The uploader was promised anonymity at submit time; shipping the author id revokes it. Note the upload above is flagged anon:true and still carries the uploader UUID.

The fix

  • fileUrl and thumbnailUrl are now absolute, expiring presigned URLs produced by getSecureUrl, so the approval gate is enforced at the point the URL is minted. Both are null when the caller may not see the bytes.
  • New urlExpiresAt tells clients when to re-fetch instead of caching a URL that will start 403ing.
  • uploadedBy and instagramHandle are null on anonymous uploads unless the caller is the uploader or holds media:upload:read.
  • Javadoc on the DTO states plainly that these are security boundaries and not cosmetics, so the next person to touch the mapping knows what they are undoing. @Schema annotations carry the same warning into the generated OpenAPI spec.

⚠️ Breaking change

fileUrl is no longer a key to concatenate onto a media base URL — it is the complete URL. See CLIENT_MIGRATION.md in this PR for exactly what utmmsa-pwa must change. The frontend repo is untouched here.

Verification

R2StorageServiceSecureUrlTest    tests=5   failures=0  errors=0
UploadServiceDtoMappingTest      tests=15  failures=0  errors=0
---
Tests run: 595, Failures: 0, Errors: 0, Skipped: 0     BUILD SUCCESS

URL shape (absolute, signed, expiring) is asserted against a mocked S3 presigner rather than a live R2 call, since this machine has no reachable bucket.

Merge note

Touches UploadService.java, which the sibling upload-IDOR PR also edits. Expect a small conflict depending on merge order — the changes are independent (this one is DTO field mapping, that one is authorization filtering).

🤖 Generated with Claude Code

https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm

UploadDto.fileUrl was the verbatim R2 object key. Verified live as an
ordinary ROLE_USER, GET /api/user/uploads returned:

  {"fileUrl":"images/e5c6f37c-...","uploadedBy":"f7b1d402-...",
   "approved":true,"anon":true}

Two defects in one response. The bucket is fronted by a public custom
domain (PosterService serves publicUrl + "/" + objectKey unsigned), so
a leaked key is a direct unauthenticated fetch of the media -- and
AdminUploadDto deliberately routes through R2StorageService.getSecureUrl,
which throws SecurityException for unapproved content. The user-facing
path skipped that boundary entirely. Separately, uploadedBy was
serialized even when anon=true, revoking the anonymity the uploader was
promised at submit time.

fileUrl and thumbnailUrl are now absolute expiring presigned URLs
issued through the approval gate, with urlExpiresAt so clients re-fetch
rather than cache. uploadedBy and instagramHandle are null on anonymous
uploads unless the caller is the uploader or holds media:upload:read.

Breaking change for utmmsa-pwa; see CLIENT_MIGRATION.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@IbraTech04

Copy link
Copy Markdown
Collaborator Author

This PR clobbers #8 and a lot of what they fix is sort of the same. It should be folded into that PR is cleaned up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant