fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a) - #239

Merged
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend
Jun 17, 2026
Merged

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a)#239
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 15, 2026

Copy link
Copy Markdown
Member

Phase 2a of #231 storage hardening — the app change that must deploy before flipping issues-media-files private (2b).

What

  • BackendPOST /api/issue-reports/screenshot: get_session_user_id (401 unauth) + content-type allowlist (415) + bounded read (413, request_limits.read_within_limit), uploads with the service role, returns the storage path.
  • FrontendReportIssueFlow: uploads via the endpoint (credentials:'include'), stores the path; drops the supabase anon storage client — removing the last live frontend anon-storage path.
  • Testtest_issue_screenshot_auth.py: 401 / 415 / 413 / 200(path scoped to user). All fail pre-fix (endpoint didn't exist).

⚠️ Scope correction worth your eye

While building, I confirmed there is no in-app reader for issue screenshotsfeedback.py only INSERTs, no GET/admin view, nothing renders screenshot_urls. It's write-only collected, exactly like résumés. So I did not build a signed-URL read endpoint: it would have no consumer (dead code). Review is via the Supabase dashboard (works on private buckets).

That changes the gate you set: there are no in-app screenshots to "render," so the pre-2b check isn't "confirm screenshots render" — it's "confirm a new issue-report submission with a screenshot succeeds via the backend in prod." If you'd rather have an in-app admin review view now (which would give the signed-URL read a real consumer), say so and I'll add it; otherwise this matches the résumé decision (dashboard review, deferred admin view).

Sequence

  1. Merge + deploy 2a (backend + frontend) to prod.
  2. Verify a new issue report with a screenshot submits cleanly (backend upload path).
  3. Then apply 2b (flip issues-media-files private + drop its two {public} policies). Existing 2 rows' public URLs stop resolving → dashboard review (same as résumés).

⚠️ Do not merge/deploy until reviewed.

Summary by CodeRabbit

  • New Features
    • Added authenticated screenshot uploads for issue reports, including file-type allowlisting and a 5MB maximum size limit.
    • Updated the issue reporting flow to upload screenshots through the backend and use the returned storage path; in local mode, it also provides a preview.
  • Tests
    • Added regression coverage for authentication enforcement, MIME/type and size validation, successful uploads, and backend upload failures.

…ackend upload (#231 Phase 2a)
Moves issue-report screenshot uploads off the frontend's public anon-key storage
client onto an auth-gated, service-role backend endpoint so issues-media-files
can be made private (Phase 2b).
- Backend: POST /api/issue-reports/screenshot — get_session_user_id (401),
content-type allowlist (415) + bounded read (413) via request_limits, uploads
with the service role, returns the storage PATH.
- Frontend: ReportIssueFlow uploads via the endpoint (credentials:include) and
stores the path; drops the supabase anon storage client (removes the last live
frontend anon-storage path).
- Test: unauth 401, wrong-type 415, oversize 413, valid 200 (path scoped to the
authed user). All fail pre-fix (endpoint didn't exist).
Note: issue screenshots have NO in-app reader (write-only, like résumés), so no
signed-URL read endpoint is included — it would have no consumer; review is via
the dashboard. screenshot_urls now stores paths for new reports.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ef35458-80a2-4924-8f70-9300932d576f

📥 Commits

Reviewing files that changed from the base of the PR and between 1807a91 and 500c025.

📒 Files selected for processing (1)
  • backend/routes/feedback.py
📝 Walkthrough

Walkthrough

Moves issue-report screenshot uploads from direct Supabase client-side uploads to a new authenticated server-side FastAPI endpoint (POST /issue-reports/screenshot). The backend validates MIME type, enforces a 5MB size limit, and uploads using the service role key. The frontend uploadScreenshot helper is updated to call this endpoint instead.

Changes

Screenshot Upload via Backend Endpoint

Layer / File(s)Summary
Backend screenshot upload endpoint
backend/routes/feedback.py
Adds ISSUE_BUCKET, MAX_SCREENSHOT_BYTES, ALLOWED_SCREENSHOT_TYPES constants and the authenticated POST /issue-reports/screenshot handler: validates content type (415), enforces size limit (413), rejects empty bodies (400), constructs a user-scoped storage path, uploads bytes to Supabase via service role credentials, and returns the storage path.
Frontend uploadScreenshot refactor
frontend/src/components/ReportIssueFlow.tsx
Removes direct Supabase client upload logic (supabase import, BUCKET, userId-based path). Replaces with a fetch call to /api/issue-reports/screenshot, returns the backend-provided path, and drops userId from the submit call site. Local-mode blob URL behavior is preserved.
Endpoint regression tests
backend/tests/test_issue_screenshot_auth.py
Adds TestIssueScreenshotUpload with cases covering 401 on unauthenticated access, 415 for wrong MIME type (no outbound upload), 413 for oversized payload (no outbound upload), 200 with user-scoped .png path for a valid upload (exactly one httpx.put call), and 502 for upstream failures while preventing sensitive header/URL leakage.

Sequence Diagram

sequenceDiagram
participant Browser as Browser (ReportIssueFlow)
participant API as FastAPI /issue-reports/screenshot
participant Supabase as Supabase Storage
Browser->>API: POST /api/issue-reports/screenshot (UploadFile + session cookie)
API->>API: decode session → user_id (401 if missing)
API->>API: check content_type vs allowlist (415 on mismatch)
API->>API: read body ≤ 5 MB (413 on oversize, 400 if empty)
API->>Supabase: PUT {user_id}/screenshots/{uuid}.{ext} with service role key
Supabase-->>API: 200 OK
API-->>Browser: {"path": "{user_id}/screenshots/{uuid}.{ext}"}
Browser->>Browser: use path as screenshot_urls in issue report submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A screenshot once flew straight to the cloud,
Now hops through the backend — auth-checked and proud.
The bucket sits safe behind token and gate,
No direct upload shall bypass our state.
Five megabytes max, and the type must be right,
This rabbit secured the uploads tonight! 🖼️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 18.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: auth-gated backend upload for issue-report screenshots as Phase 2a of storage hardening work.
Description check✅ PassedThe description is comprehensive and covers all key sections: what the changes do, specific backend/frontend/test changes, testing status, deployment sequence, and important scope clarifications about screenshot review.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/231-phase2a-issue-screenshot-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend500c025Commit Preview URL

Branch Preview URL
Jun 17 2026, 01:48 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/routes/feedback.py (1)

78-78: 💤 Low value

Dead-code fallback: file.content_type is already validated non-None.

Line 57 rejects requests where (file.content_type or "") is not in ALLOWED_SCREENSHOT_TYPES, so by the time line 78 executes, file.content_type is guaranteed to be a valid string from the allowlist. The or "application/octet-stream" fallback is unreachable.

🧹 Simplify to reflect the invariant
- "Content-Type": file.content_type or "application/octet-stream",+ "Content-Type": file.content_type,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 78, The `or "application/octet-stream"`
fallback in the Content-Type header assignment is unreachable dead code because
the validation at line 57 already ensures that `file.content_type` is a non-None
value from the ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator
and simplify the expression to use `file.content_type` directly, reflecting the
guaranteed invariant at that point in the code.
backend/tests/test_issue_screenshot_auth.py (1)

48-57: 💤 Low value

Test will break if the async httpx fix is applied.

If the endpoint is updated to use httpx.AsyncClient (as suggested for the blocking I/O issue), this mock will need adjustment since patch("routes.feedback.httpx") mocks the synchronous module-level usage.

For an AsyncClient refactor, you'd mock the async context manager and its methods. No action needed now, but keep this in mind when applying the async fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_issue_screenshot_auth.py` around lines 48 - 57, The
current test mocks the synchronous httpx module-level usage. When the endpoint
is refactored to use httpx.AsyncClient, this test will need to be updated to
properly mock the async context manager and its async methods. Specifically,
update the patch target from the module-level httpx to the AsyncClient class,
mock the async context manager's __aenter__ and __aexit__ methods, and ensure
the mocked put method is set up as a coroutine or async mock that can be awaited
within the async context. This adjustment will be necessary once the blocking
I/O issue is addressed and httpx.AsyncClient is introduced in the
routes.feedback module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/feedback.py`:
- Around line 72-81: Replace the synchronous `httpx.put()` call with an async
equivalent to prevent blocking the event loop. Use `httpx.AsyncClient()` with
`await` to make the call non-blocking, and add a timeout parameter to the
request to prevent indefinite hangs if Supabase Storage is unresponsive. You can
either create a new AsyncClient for each request or follow the module-level
client pattern (similar to the sync `_client` pattern mentioned in the comment)
by instantiating an async client at import time and reusing it across requests
for better performance.
---
Nitpick comments:
In `@backend/routes/feedback.py`:
- Line 78: The `or "application/octet-stream"` fallback in the Content-Type
header assignment is unreachable dead code because the validation at line 57
already ensures that `file.content_type` is a non-None value from the
ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator and simplify
the expression to use `file.content_type` directly, reflecting the guaranteed
invariant at that point in the code.
In `@backend/tests/test_issue_screenshot_auth.py`:
- Around line 48-57: The current test mocks the synchronous httpx module-level
usage. When the endpoint is refactored to use httpx.AsyncClient, this test will
need to be updated to properly mock the async context manager and its async
methods. Specifically, update the patch target from the module-level httpx to
the AsyncClient class, mock the async context manager's __aenter__ and __aexit__
methods, and ensure the mocked put method is set up as a coroutine or async mock
that can be awaited within the async context. This adjustment will be necessary
once the blocking I/O issue is addressed and httpx.AsyncClient is introduced in
the routes.feedback module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10aca5a2-e177-41c4-a2f0-6de68c683b63

📥 Commits

Reviewing files that changed from the base of the PR and between ee5b8f1 and 6d6226f.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx

Comment threadbackend/routes/feedback.py Outdated
Comment on lines +72 to +81
r = httpx.put(
url,
content=content,
headers={
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
"Content-Type": file.content_type or "application/octet-stream",
},
)
r.raise_for_status()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronous httpx.put blocks the async event loop.

httpx.put() is a synchronous call inside an async def function. Under concurrent load, each upload blocks the entire event loop until the Supabase round-trip completes, starving other requests.

Additionally, no timeout is specified—if Supabase Storage is slow or unresponsive, the call can hang indefinitely.

🔧 Proposed fix using async httpx with timeout
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- )- r.raise_for_status()+ async with httpx.AsyncClient(timeout=30.0) as http:+ r = await http.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type, # already validated non-None+ },+ )+ r.raise_for_status()

Alternatively, if you prefer to reuse a module-level async client (similar to the sync _client pattern in connection.py), you could instantiate one at import time and reuse it across requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 72 - 81, Replace the synchronous
`httpx.put()` call with an async equivalent to prevent blocking the event loop.
Use `httpx.AsyncClient()` with `await` to make the call non-blocking, and add a
timeout parameter to the request to prevent indefinite hangs if Supabase Storage
is unresponsive. You can either create a new AsyncClient for each request or
follow the module-level client pattern (similar to the sync `_client` pattern
mentioned in the comment) by instantiating an async client at import time and
reusing it across requests for better performance.

…g, timeout
Address PR #239 review:
- ReportIssueFlow.tsx: prefix screenshot upload fetch with API_URL (frontend
and backend are separate origins; bare path hit the frontend host).
- routes/feedback.py: replace r.raise_for_status() with the established
storage_service.upload_avatar pattern — check status, log, and raise
HTTPException(502) with a truncated upstream body (no URL/headers, which
carry the service-role key) instead of a generic 500.
- routes/feedback.py: add timeout=30.0 to the httpx.put (matches db client).
- test_issue_screenshot_auth.py: add Supabase-failure test asserting 502
(not 500) and that the key/URL don't leak; update the success-path mock to
set status_code=200 now that raise_for_status is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/feedback.py (3)

21-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Derive the object extension from the validated MIME type.

file.filename is user-controlled; copying its suffix can produce mismatched paths or URL-significant characters. Since the MIME type is already allowlisted, map it to a known extension.

🐛 Proposed fix
-ALLOWED_SCREENSHOT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}+SCREENSHOT_EXTENSIONS = {+ "image/png": "png",+ "image/jpeg": "jpg",+ "image/webp": "webp",+ "image/gif": "gif",+}+ALLOWED_SCREENSHOT_TYPES = set(SCREENSHOT_EXTENSIONS)
- if (file.content_type or "") not in ALLOWED_SCREENSHOT_TYPES:+ content_type = file.content_type or ""+ if content_type not in ALLOWED_SCREENSHOT_TYPES:
raise HTTPException(
status_code=415,
detail="Unsupported image type. Allowed: PNG, JPEG, WEBP, GIF.",
)
content = await read_within_limit(file, MAX_SCREENSHOT_BYTES) # 413 if oversize
if not content:
raise HTTPException(status_code=400, detail="Empty file.")
- ext = (- file.filename.rsplit(".", 1)[-1]- if file.filename and "." in file.filename- else "png"- )+ ext = SCREENSHOT_EXTENSIONS[content_type]

Also applies to: 60-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 21, The issue is that file.filename is
user-controlled and using its extension directly can produce mismatched file
paths or unexpected characters. Since the MIME type is already validated against
the ALLOWED_SCREENSHOT_TYPES allowlist, create a mapping from MIME types to
their corresponding file extensions (e.g., image/png to .png, image/jpeg to
.jpg, etc.). Then, wherever the file extension is used (in the code around lines
60-73 where file saving occurs), derive it from the validated MIME type using
this mapping instead of extracting it from the user-provided file.filename.

74-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep service-role storage writes behind the Supabase access boundary.

This route composes the Supabase Storage URL and sends SUPABASE_KEY directly from route code. Move the upload into the approved DB/storage boundary, or add an approved helper there, so service-role handling stays centralized.

As per coding guidelines, "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 74 - 83, The direct httpx.put call
to SUPABASE_URL with SUPABASE_KEY in the feedback route violates the approved
Supabase access pattern. Move the file upload logic (the httpx.put call that
constructs the Supabase Storage URL and sends the SUPABASE_KEY) from this route
into a dedicated helper function in backend/db/connection.py, similar to the
table() interface. Then replace the httpx.put code in the feedback route with a
call to this new helper function, ensuring all service-role storage operations
are centralized and go through the approved connection boundary.

Source: Coding guidelines


75-99: ⚠️ Potential issue | 🟠 Major

Map transport failures to 502 too.

timeout=30.0 now bounds the call, but timeout/connect/DNS failures raise before r exists, bypassing the status-code mapping and surfacing as 500s.

🐛 Proposed fix
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- timeout=30.0,- )+ try:+ r = httpx.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type or "application/octet-stream",+ },+ timeout=30.0,+ )+ except httpx.RequestError as exc:+ logger.warning(+ "upload_issue_screenshot: Supabase storage request failed "+ "user=%s error=%s",+ user_id,+ exc.__class__.__name__,+ )+ raise HTTPException(+ status_code=502,+ detail="Screenshot upload failed: storage service unavailable.",+ ) from exc
if r.status_code not in (200, 201):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 75 - 99, The current error handling
in the upload screenshot endpoint only catches HTTP status code failures from
the httpx.put call, but transport-level failures such as timeouts, connection
errors, or DNS failures will raise exceptions before the response object r is
created, bypassing the error mapping and surfacing as 500 errors instead of
502s. Wrap the httpx.put call in a try-except block to catch httpx transport
exceptions (such as TimeoutException, ConnectError, and similar) and raise the
same HTTPException with status_code 502, following the same pattern as the
existing status code check, to ensure all Supabase storage failures consistently
map to 502 responses.
frontend/src/components/ReportIssueFlow.tsx (1)

63-77: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mirror the backend screenshot MIME allowlist in the picker.

The UI accepts any image/*, but the backend only accepts PNG, JPEG, WEBP, and GIF. SVG/HEIC/BMP files pass client validation and then fail during submit with 415.

🐛 Proposed fix
 const MAX_SCREENSHOTS = 5;
const MAX_BYTES = 5 * 1024 * 1024;
+const ALLOWED_SCREENSHOT_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);+const ACCEPTED_SCREENSHOT_TYPES = "image/png,image/jpeg,image/webp,image/gif";
- if (!f.type.startsWith("image/")) {- toast.error(`${f.name} isn't an image`);+ if (!ALLOWED_SCREENSHOT_TYPES.has(f.type)) {+ toast.error(`${f.name} must be PNG, JPEG, WEBP, or GIF`);
return;
}
- accept="image/*"+ accept={ACCEPTED_SCREENSHOT_TYPES}

Also applies to: 240-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ReportIssueFlow.tsx` around lines 63 - 77, The
addFiles function currently validates image files too broadly by accepting any
f.type that starts with "image/", but the backend only accepts PNG, JPEG, WEBP,
and GIF formats. Update the MIME type validation check (currently checking
f.type.startsWith("image/")) to instead validate against a specific allowlist of
only the backend-supported formats: image/png, image/jpeg, image/webp, and
image/gif. This will prevent non-supported image types like SVG, HEIC, and BMP
from passing client validation and subsequently failing with a 415 error during
submission. Apply the same fix to the additional validation mentioned at lines
240-246.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@backend/routes/feedback.py`:
- Line 21: The issue is that file.filename is user-controlled and using its
extension directly can produce mismatched file paths or unexpected characters.
Since the MIME type is already validated against the ALLOWED_SCREENSHOT_TYPES
allowlist, create a mapping from MIME types to their corresponding file
extensions (e.g., image/png to .png, image/jpeg to .jpg, etc.). Then, wherever
the file extension is used (in the code around lines 60-73 where file saving
occurs), derive it from the validated MIME type using this mapping instead of
extracting it from the user-provided file.filename.
- Around line 74-83: The direct httpx.put call to SUPABASE_URL with SUPABASE_KEY
in the feedback route violates the approved Supabase access pattern. Move the
file upload logic (the httpx.put call that constructs the Supabase Storage URL
and sends the SUPABASE_KEY) from this route into a dedicated helper function in
backend/db/connection.py, similar to the table() interface. Then replace the
httpx.put code in the feedback route with a call to this new helper function,
ensuring all service-role storage operations are centralized and go through the
approved connection boundary.
- Around line 75-99: The current error handling in the upload screenshot
endpoint only catches HTTP status code failures from the httpx.put call, but
transport-level failures such as timeouts, connection errors, or DNS failures
will raise exceptions before the response object r is created, bypassing the
error mapping and surfacing as 500 errors instead of 502s. Wrap the httpx.put
call in a try-except block to catch httpx transport exceptions (such as
TimeoutException, ConnectError, and similar) and raise the same HTTPException
with status_code 502, following the same pattern as the existing status code
check, to ensure all Supabase storage failures consistently map to 502
responses.
In `@frontend/src/components/ReportIssueFlow.tsx`:
- Around line 63-77: The addFiles function currently validates image files too
broadly by accepting any f.type that starts with "image/", but the backend only
accepts PNG, JPEG, WEBP, and GIF formats. Update the MIME type validation check
(currently checking f.type.startsWith("image/")) to instead validate against a
specific allowlist of only the backend-supported formats: image/png, image/jpeg,
image/webp, and image/gif. This will prevent non-supported image types like SVG,
HEIC, and BMP from passing client validation and subsequently failing with a 415
error during submission. Apply the same fix to the additional validation
mentioned at lines 240-246.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8035ad70-4236-4eb7-afef-d338421399e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6226f and 1807a91.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_issue_screenshot_auth.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 8c71463 into mainJun 17, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the security/231-phase2a-issue-screenshot-backend branch June 17, 2026 01:49
Sign up for freeto 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

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a) - #239

Merged
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend
Jun 17, 2026
Merged

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a)#239
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 15, 2026

Copy link
Copy Markdown
Member

Phase 2a of #231 storage hardening — the app change that must deploy before flipping issues-media-files private (2b).

What

  • BackendPOST /api/issue-reports/screenshot: get_session_user_id (401 unauth) + content-type allowlist (415) + bounded read (413, request_limits.read_within_limit), uploads with the service role, returns the storage path.
  • FrontendReportIssueFlow: uploads via the endpoint (credentials:'include'), stores the path; drops the supabase anon storage client — removing the last live frontend anon-storage path.
  • Testtest_issue_screenshot_auth.py: 401 / 415 / 413 / 200(path scoped to user). All fail pre-fix (endpoint didn't exist).

⚠️ Scope correction worth your eye

While building, I confirmed there is no in-app reader for issue screenshotsfeedback.py only INSERTs, no GET/admin view, nothing renders screenshot_urls. It's write-only collected, exactly like résumés. So I did not build a signed-URL read endpoint: it would have no consumer (dead code). Review is via the Supabase dashboard (works on private buckets).

That changes the gate you set: there are no in-app screenshots to "render," so the pre-2b check isn't "confirm screenshots render" — it's "confirm a new issue-report submission with a screenshot succeeds via the backend in prod." If you'd rather have an in-app admin review view now (which would give the signed-URL read a real consumer), say so and I'll add it; otherwise this matches the résumé decision (dashboard review, deferred admin view).

Sequence

  1. Merge + deploy 2a (backend + frontend) to prod.
  2. Verify a new issue report with a screenshot submits cleanly (backend upload path).
  3. Then apply 2b (flip issues-media-files private + drop its two {public} policies). Existing 2 rows' public URLs stop resolving → dashboard review (same as résumés).

⚠️ Do not merge/deploy until reviewed.

Summary by CodeRabbit

  • New Features
    • Added authenticated screenshot uploads for issue reports, including file-type allowlisting and a 5MB maximum size limit.
    • Updated the issue reporting flow to upload screenshots through the backend and use the returned storage path; in local mode, it also provides a preview.
  • Tests
    • Added regression coverage for authentication enforcement, MIME/type and size validation, successful uploads, and backend upload failures.

…ackend upload (#231 Phase 2a)
Moves issue-report screenshot uploads off the frontend's public anon-key storage
client onto an auth-gated, service-role backend endpoint so issues-media-files
can be made private (Phase 2b).
- Backend: POST /api/issue-reports/screenshot — get_session_user_id (401),
content-type allowlist (415) + bounded read (413) via request_limits, uploads
with the service role, returns the storage PATH.
- Frontend: ReportIssueFlow uploads via the endpoint (credentials:include) and
stores the path; drops the supabase anon storage client (removes the last live
frontend anon-storage path).
- Test: unauth 401, wrong-type 415, oversize 413, valid 200 (path scoped to the
authed user). All fail pre-fix (endpoint didn't exist).
Note: issue screenshots have NO in-app reader (write-only, like résumés), so no
signed-URL read endpoint is included — it would have no consumer; review is via
the dashboard. screenshot_urls now stores paths for new reports.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ef35458-80a2-4924-8f70-9300932d576f

📥 Commits

Reviewing files that changed from the base of the PR and between 1807a91 and 500c025.

📒 Files selected for processing (1)
  • backend/routes/feedback.py
📝 Walkthrough

Walkthrough

Moves issue-report screenshot uploads from direct Supabase client-side uploads to a new authenticated server-side FastAPI endpoint (POST /issue-reports/screenshot). The backend validates MIME type, enforces a 5MB size limit, and uploads using the service role key. The frontend uploadScreenshot helper is updated to call this endpoint instead.

Changes

Screenshot Upload via Backend Endpoint

Layer / File(s)Summary
Backend screenshot upload endpoint
backend/routes/feedback.py
Adds ISSUE_BUCKET, MAX_SCREENSHOT_BYTES, ALLOWED_SCREENSHOT_TYPES constants and the authenticated POST /issue-reports/screenshot handler: validates content type (415), enforces size limit (413), rejects empty bodies (400), constructs a user-scoped storage path, uploads bytes to Supabase via service role credentials, and returns the storage path.
Frontend uploadScreenshot refactor
frontend/src/components/ReportIssueFlow.tsx
Removes direct Supabase client upload logic (supabase import, BUCKET, userId-based path). Replaces with a fetch call to /api/issue-reports/screenshot, returns the backend-provided path, and drops userId from the submit call site. Local-mode blob URL behavior is preserved.
Endpoint regression tests
backend/tests/test_issue_screenshot_auth.py
Adds TestIssueScreenshotUpload with cases covering 401 on unauthenticated access, 415 for wrong MIME type (no outbound upload), 413 for oversized payload (no outbound upload), 200 with user-scoped .png path for a valid upload (exactly one httpx.put call), and 502 for upstream failures while preventing sensitive header/URL leakage.

Sequence Diagram

sequenceDiagram
participant Browser as Browser (ReportIssueFlow)
participant API as FastAPI /issue-reports/screenshot
participant Supabase as Supabase Storage
Browser->>API: POST /api/issue-reports/screenshot (UploadFile + session cookie)
API->>API: decode session → user_id (401 if missing)
API->>API: check content_type vs allowlist (415 on mismatch)
API->>API: read body ≤ 5 MB (413 on oversize, 400 if empty)
API->>Supabase: PUT {user_id}/screenshots/{uuid}.{ext} with service role key
Supabase-->>API: 200 OK
API-->>Browser: {"path": "{user_id}/screenshots/{uuid}.{ext}"}
Browser->>Browser: use path as screenshot_urls in issue report submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A screenshot once flew straight to the cloud,
Now hops through the backend — auth-checked and proud.
The bucket sits safe behind token and gate,
No direct upload shall bypass our state.
Five megabytes max, and the type must be right,
This rabbit secured the uploads tonight! 🖼️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 18.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: auth-gated backend upload for issue-report screenshots as Phase 2a of storage hardening work.
Description check✅ PassedThe description is comprehensive and covers all key sections: what the changes do, specific backend/frontend/test changes, testing status, deployment sequence, and important scope clarifications about screenshot review.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/231-phase2a-issue-screenshot-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend500c025Commit Preview URL

Branch Preview URL
Jun 17 2026, 01:48 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/routes/feedback.py (1)

78-78: 💤 Low value

Dead-code fallback: file.content_type is already validated non-None.

Line 57 rejects requests where (file.content_type or "") is not in ALLOWED_SCREENSHOT_TYPES, so by the time line 78 executes, file.content_type is guaranteed to be a valid string from the allowlist. The or "application/octet-stream" fallback is unreachable.

🧹 Simplify to reflect the invariant
- "Content-Type": file.content_type or "application/octet-stream",+ "Content-Type": file.content_type,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 78, The `or "application/octet-stream"`
fallback in the Content-Type header assignment is unreachable dead code because
the validation at line 57 already ensures that `file.content_type` is a non-None
value from the ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator
and simplify the expression to use `file.content_type` directly, reflecting the
guaranteed invariant at that point in the code.
backend/tests/test_issue_screenshot_auth.py (1)

48-57: 💤 Low value

Test will break if the async httpx fix is applied.

If the endpoint is updated to use httpx.AsyncClient (as suggested for the blocking I/O issue), this mock will need adjustment since patch("routes.feedback.httpx") mocks the synchronous module-level usage.

For an AsyncClient refactor, you'd mock the async context manager and its methods. No action needed now, but keep this in mind when applying the async fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_issue_screenshot_auth.py` around lines 48 - 57, The
current test mocks the synchronous httpx module-level usage. When the endpoint
is refactored to use httpx.AsyncClient, this test will need to be updated to
properly mock the async context manager and its async methods. Specifically,
update the patch target from the module-level httpx to the AsyncClient class,
mock the async context manager's __aenter__ and __aexit__ methods, and ensure
the mocked put method is set up as a coroutine or async mock that can be awaited
within the async context. This adjustment will be necessary once the blocking
I/O issue is addressed and httpx.AsyncClient is introduced in the
routes.feedback module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/feedback.py`:
- Around line 72-81: Replace the synchronous `httpx.put()` call with an async
equivalent to prevent blocking the event loop. Use `httpx.AsyncClient()` with
`await` to make the call non-blocking, and add a timeout parameter to the
request to prevent indefinite hangs if Supabase Storage is unresponsive. You can
either create a new AsyncClient for each request or follow the module-level
client pattern (similar to the sync `_client` pattern mentioned in the comment)
by instantiating an async client at import time and reusing it across requests
for better performance.
---
Nitpick comments:
In `@backend/routes/feedback.py`:
- Line 78: The `or "application/octet-stream"` fallback in the Content-Type
header assignment is unreachable dead code because the validation at line 57
already ensures that `file.content_type` is a non-None value from the
ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator and simplify
the expression to use `file.content_type` directly, reflecting the guaranteed
invariant at that point in the code.
In `@backend/tests/test_issue_screenshot_auth.py`:
- Around line 48-57: The current test mocks the synchronous httpx module-level
usage. When the endpoint is refactored to use httpx.AsyncClient, this test will
need to be updated to properly mock the async context manager and its async
methods. Specifically, update the patch target from the module-level httpx to
the AsyncClient class, mock the async context manager's __aenter__ and __aexit__
methods, and ensure the mocked put method is set up as a coroutine or async mock
that can be awaited within the async context. This adjustment will be necessary
once the blocking I/O issue is addressed and httpx.AsyncClient is introduced in
the routes.feedback module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10aca5a2-e177-41c4-a2f0-6de68c683b63

📥 Commits

Reviewing files that changed from the base of the PR and between ee5b8f1 and 6d6226f.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx

Comment threadbackend/routes/feedback.py Outdated
Comment on lines +72 to +81
r = httpx.put(
url,
content=content,
headers={
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
"Content-Type": file.content_type or "application/octet-stream",
},
)
r.raise_for_status()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronous httpx.put blocks the async event loop.

httpx.put() is a synchronous call inside an async def function. Under concurrent load, each upload blocks the entire event loop until the Supabase round-trip completes, starving other requests.

Additionally, no timeout is specified—if Supabase Storage is slow or unresponsive, the call can hang indefinitely.

🔧 Proposed fix using async httpx with timeout
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- )- r.raise_for_status()+ async with httpx.AsyncClient(timeout=30.0) as http:+ r = await http.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type, # already validated non-None+ },+ )+ r.raise_for_status()

Alternatively, if you prefer to reuse a module-level async client (similar to the sync _client pattern in connection.py), you could instantiate one at import time and reuse it across requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 72 - 81, Replace the synchronous
`httpx.put()` call with an async equivalent to prevent blocking the event loop.
Use `httpx.AsyncClient()` with `await` to make the call non-blocking, and add a
timeout parameter to the request to prevent indefinite hangs if Supabase Storage
is unresponsive. You can either create a new AsyncClient for each request or
follow the module-level client pattern (similar to the sync `_client` pattern
mentioned in the comment) by instantiating an async client at import time and
reusing it across requests for better performance.

…g, timeout
Address PR #239 review:
- ReportIssueFlow.tsx: prefix screenshot upload fetch with API_URL (frontend
and backend are separate origins; bare path hit the frontend host).
- routes/feedback.py: replace r.raise_for_status() with the established
storage_service.upload_avatar pattern — check status, log, and raise
HTTPException(502) with a truncated upstream body (no URL/headers, which
carry the service-role key) instead of a generic 500.
- routes/feedback.py: add timeout=30.0 to the httpx.put (matches db client).
- test_issue_screenshot_auth.py: add Supabase-failure test asserting 502
(not 500) and that the key/URL don't leak; update the success-path mock to
set status_code=200 now that raise_for_status is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/feedback.py (3)

21-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Derive the object extension from the validated MIME type.

file.filename is user-controlled; copying its suffix can produce mismatched paths or URL-significant characters. Since the MIME type is already allowlisted, map it to a known extension.

🐛 Proposed fix
-ALLOWED_SCREENSHOT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}+SCREENSHOT_EXTENSIONS = {+ "image/png": "png",+ "image/jpeg": "jpg",+ "image/webp": "webp",+ "image/gif": "gif",+}+ALLOWED_SCREENSHOT_TYPES = set(SCREENSHOT_EXTENSIONS)
- if (file.content_type or "") not in ALLOWED_SCREENSHOT_TYPES:+ content_type = file.content_type or ""+ if content_type not in ALLOWED_SCREENSHOT_TYPES:
raise HTTPException(
status_code=415,
detail="Unsupported image type. Allowed: PNG, JPEG, WEBP, GIF.",
)
content = await read_within_limit(file, MAX_SCREENSHOT_BYTES) # 413 if oversize
if not content:
raise HTTPException(status_code=400, detail="Empty file.")
- ext = (- file.filename.rsplit(".", 1)[-1]- if file.filename and "." in file.filename- else "png"- )+ ext = SCREENSHOT_EXTENSIONS[content_type]

Also applies to: 60-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 21, The issue is that file.filename is
user-controlled and using its extension directly can produce mismatched file
paths or unexpected characters. Since the MIME type is already validated against
the ALLOWED_SCREENSHOT_TYPES allowlist, create a mapping from MIME types to
their corresponding file extensions (e.g., image/png to .png, image/jpeg to
.jpg, etc.). Then, wherever the file extension is used (in the code around lines
60-73 where file saving occurs), derive it from the validated MIME type using
this mapping instead of extracting it from the user-provided file.filename.

74-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep service-role storage writes behind the Supabase access boundary.

This route composes the Supabase Storage URL and sends SUPABASE_KEY directly from route code. Move the upload into the approved DB/storage boundary, or add an approved helper there, so service-role handling stays centralized.

As per coding guidelines, "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 74 - 83, The direct httpx.put call
to SUPABASE_URL with SUPABASE_KEY in the feedback route violates the approved
Supabase access pattern. Move the file upload logic (the httpx.put call that
constructs the Supabase Storage URL and sends the SUPABASE_KEY) from this route
into a dedicated helper function in backend/db/connection.py, similar to the
table() interface. Then replace the httpx.put code in the feedback route with a
call to this new helper function, ensuring all service-role storage operations
are centralized and go through the approved connection boundary.

Source: Coding guidelines


75-99: ⚠️ Potential issue | 🟠 Major

Map transport failures to 502 too.

timeout=30.0 now bounds the call, but timeout/connect/DNS failures raise before r exists, bypassing the status-code mapping and surfacing as 500s.

🐛 Proposed fix
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- timeout=30.0,- )+ try:+ r = httpx.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type or "application/octet-stream",+ },+ timeout=30.0,+ )+ except httpx.RequestError as exc:+ logger.warning(+ "upload_issue_screenshot: Supabase storage request failed "+ "user=%s error=%s",+ user_id,+ exc.__class__.__name__,+ )+ raise HTTPException(+ status_code=502,+ detail="Screenshot upload failed: storage service unavailable.",+ ) from exc
if r.status_code not in (200, 201):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 75 - 99, The current error handling
in the upload screenshot endpoint only catches HTTP status code failures from
the httpx.put call, but transport-level failures such as timeouts, connection
errors, or DNS failures will raise exceptions before the response object r is
created, bypassing the error mapping and surfacing as 500 errors instead of
502s. Wrap the httpx.put call in a try-except block to catch httpx transport
exceptions (such as TimeoutException, ConnectError, and similar) and raise the
same HTTPException with status_code 502, following the same pattern as the
existing status code check, to ensure all Supabase storage failures consistently
map to 502 responses.
frontend/src/components/ReportIssueFlow.tsx (1)

63-77: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mirror the backend screenshot MIME allowlist in the picker.

The UI accepts any image/*, but the backend only accepts PNG, JPEG, WEBP, and GIF. SVG/HEIC/BMP files pass client validation and then fail during submit with 415.

🐛 Proposed fix
 const MAX_SCREENSHOTS = 5;
const MAX_BYTES = 5 * 1024 * 1024;
+const ALLOWED_SCREENSHOT_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);+const ACCEPTED_SCREENSHOT_TYPES = "image/png,image/jpeg,image/webp,image/gif";
- if (!f.type.startsWith("image/")) {- toast.error(`${f.name} isn't an image`);+ if (!ALLOWED_SCREENSHOT_TYPES.has(f.type)) {+ toast.error(`${f.name} must be PNG, JPEG, WEBP, or GIF`);
return;
}
- accept="image/*"+ accept={ACCEPTED_SCREENSHOT_TYPES}

Also applies to: 240-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ReportIssueFlow.tsx` around lines 63 - 77, The
addFiles function currently validates image files too broadly by accepting any
f.type that starts with "image/", but the backend only accepts PNG, JPEG, WEBP,
and GIF formats. Update the MIME type validation check (currently checking
f.type.startsWith("image/")) to instead validate against a specific allowlist of
only the backend-supported formats: image/png, image/jpeg, image/webp, and
image/gif. This will prevent non-supported image types like SVG, HEIC, and BMP
from passing client validation and subsequently failing with a 415 error during
submission. Apply the same fix to the additional validation mentioned at lines
240-246.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@backend/routes/feedback.py`:
- Line 21: The issue is that file.filename is user-controlled and using its
extension directly can produce mismatched file paths or unexpected characters.
Since the MIME type is already validated against the ALLOWED_SCREENSHOT_TYPES
allowlist, create a mapping from MIME types to their corresponding file
extensions (e.g., image/png to .png, image/jpeg to .jpg, etc.). Then, wherever
the file extension is used (in the code around lines 60-73 where file saving
occurs), derive it from the validated MIME type using this mapping instead of
extracting it from the user-provided file.filename.
- Around line 74-83: The direct httpx.put call to SUPABASE_URL with SUPABASE_KEY
in the feedback route violates the approved Supabase access pattern. Move the
file upload logic (the httpx.put call that constructs the Supabase Storage URL
and sends the SUPABASE_KEY) from this route into a dedicated helper function in
backend/db/connection.py, similar to the table() interface. Then replace the
httpx.put code in the feedback route with a call to this new helper function,
ensuring all service-role storage operations are centralized and go through the
approved connection boundary.
- Around line 75-99: The current error handling in the upload screenshot
endpoint only catches HTTP status code failures from the httpx.put call, but
transport-level failures such as timeouts, connection errors, or DNS failures
will raise exceptions before the response object r is created, bypassing the
error mapping and surfacing as 500 errors instead of 502s. Wrap the httpx.put
call in a try-except block to catch httpx transport exceptions (such as
TimeoutException, ConnectError, and similar) and raise the same HTTPException
with status_code 502, following the same pattern as the existing status code
check, to ensure all Supabase storage failures consistently map to 502
responses.
In `@frontend/src/components/ReportIssueFlow.tsx`:
- Around line 63-77: The addFiles function currently validates image files too
broadly by accepting any f.type that starts with "image/", but the backend only
accepts PNG, JPEG, WEBP, and GIF formats. Update the MIME type validation check
(currently checking f.type.startsWith("image/")) to instead validate against a
specific allowlist of only the backend-supported formats: image/png, image/jpeg,
image/webp, and image/gif. This will prevent non-supported image types like SVG,
HEIC, and BMP from passing client validation and subsequently failing with a 415
error during submission. Apply the same fix to the additional validation
mentioned at lines 240-246.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8035ad70-4236-4eb7-afef-d338421399e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6226f and 1807a91.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_issue_screenshot_auth.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 8c71463 into mainJun 17, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the security/231-phase2a-issue-screenshot-backend branch June 17, 2026 01:49
Sign up for freeto 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

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a) - #239

Merged
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend
Jun 17, 2026
Merged

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a)#239
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 15, 2026

Copy link
Copy Markdown
Member

Phase 2a of #231 storage hardening — the app change that must deploy before flipping issues-media-files private (2b).

What

  • BackendPOST /api/issue-reports/screenshot: get_session_user_id (401 unauth) + content-type allowlist (415) + bounded read (413, request_limits.read_within_limit), uploads with the service role, returns the storage path.
  • FrontendReportIssueFlow: uploads via the endpoint (credentials:'include'), stores the path; drops the supabase anon storage client — removing the last live frontend anon-storage path.
  • Testtest_issue_screenshot_auth.py: 401 / 415 / 413 / 200(path scoped to user). All fail pre-fix (endpoint didn't exist).

⚠️ Scope correction worth your eye

While building, I confirmed there is no in-app reader for issue screenshotsfeedback.py only INSERTs, no GET/admin view, nothing renders screenshot_urls. It's write-only collected, exactly like résumés. So I did not build a signed-URL read endpoint: it would have no consumer (dead code). Review is via the Supabase dashboard (works on private buckets).

That changes the gate you set: there are no in-app screenshots to "render," so the pre-2b check isn't "confirm screenshots render" — it's "confirm a new issue-report submission with a screenshot succeeds via the backend in prod." If you'd rather have an in-app admin review view now (which would give the signed-URL read a real consumer), say so and I'll add it; otherwise this matches the résumé decision (dashboard review, deferred admin view).

Sequence

  1. Merge + deploy 2a (backend + frontend) to prod.
  2. Verify a new issue report with a screenshot submits cleanly (backend upload path).
  3. Then apply 2b (flip issues-media-files private + drop its two {public} policies). Existing 2 rows' public URLs stop resolving → dashboard review (same as résumés).

⚠️ Do not merge/deploy until reviewed.

Summary by CodeRabbit

  • New Features
    • Added authenticated screenshot uploads for issue reports, including file-type allowlisting and a 5MB maximum size limit.
    • Updated the issue reporting flow to upload screenshots through the backend and use the returned storage path; in local mode, it also provides a preview.
  • Tests
    • Added regression coverage for authentication enforcement, MIME/type and size validation, successful uploads, and backend upload failures.

…ackend upload (#231 Phase 2a)
Moves issue-report screenshot uploads off the frontend's public anon-key storage
client onto an auth-gated, service-role backend endpoint so issues-media-files
can be made private (Phase 2b).
- Backend: POST /api/issue-reports/screenshot — get_session_user_id (401),
content-type allowlist (415) + bounded read (413) via request_limits, uploads
with the service role, returns the storage PATH.
- Frontend: ReportIssueFlow uploads via the endpoint (credentials:include) and
stores the path; drops the supabase anon storage client (removes the last live
frontend anon-storage path).
- Test: unauth 401, wrong-type 415, oversize 413, valid 200 (path scoped to the
authed user). All fail pre-fix (endpoint didn't exist).
Note: issue screenshots have NO in-app reader (write-only, like résumés), so no
signed-URL read endpoint is included — it would have no consumer; review is via
the dashboard. screenshot_urls now stores paths for new reports.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ef35458-80a2-4924-8f70-9300932d576f

📥 Commits

Reviewing files that changed from the base of the PR and between 1807a91 and 500c025.

📒 Files selected for processing (1)
  • backend/routes/feedback.py
📝 Walkthrough

Walkthrough

Moves issue-report screenshot uploads from direct Supabase client-side uploads to a new authenticated server-side FastAPI endpoint (POST /issue-reports/screenshot). The backend validates MIME type, enforces a 5MB size limit, and uploads using the service role key. The frontend uploadScreenshot helper is updated to call this endpoint instead.

Changes

Screenshot Upload via Backend Endpoint

Layer / File(s)Summary
Backend screenshot upload endpoint
backend/routes/feedback.py
Adds ISSUE_BUCKET, MAX_SCREENSHOT_BYTES, ALLOWED_SCREENSHOT_TYPES constants and the authenticated POST /issue-reports/screenshot handler: validates content type (415), enforces size limit (413), rejects empty bodies (400), constructs a user-scoped storage path, uploads bytes to Supabase via service role credentials, and returns the storage path.
Frontend uploadScreenshot refactor
frontend/src/components/ReportIssueFlow.tsx
Removes direct Supabase client upload logic (supabase import, BUCKET, userId-based path). Replaces with a fetch call to /api/issue-reports/screenshot, returns the backend-provided path, and drops userId from the submit call site. Local-mode blob URL behavior is preserved.
Endpoint regression tests
backend/tests/test_issue_screenshot_auth.py
Adds TestIssueScreenshotUpload with cases covering 401 on unauthenticated access, 415 for wrong MIME type (no outbound upload), 413 for oversized payload (no outbound upload), 200 with user-scoped .png path for a valid upload (exactly one httpx.put call), and 502 for upstream failures while preventing sensitive header/URL leakage.

Sequence Diagram

sequenceDiagram
participant Browser as Browser (ReportIssueFlow)
participant API as FastAPI /issue-reports/screenshot
participant Supabase as Supabase Storage
Browser->>API: POST /api/issue-reports/screenshot (UploadFile + session cookie)
API->>API: decode session → user_id (401 if missing)
API->>API: check content_type vs allowlist (415 on mismatch)
API->>API: read body ≤ 5 MB (413 on oversize, 400 if empty)
API->>Supabase: PUT {user_id}/screenshots/{uuid}.{ext} with service role key
Supabase-->>API: 200 OK
API-->>Browser: {"path": "{user_id}/screenshots/{uuid}.{ext}"}
Browser->>Browser: use path as screenshot_urls in issue report submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A screenshot once flew straight to the cloud,
Now hops through the backend — auth-checked and proud.
The bucket sits safe behind token and gate,
No direct upload shall bypass our state.
Five megabytes max, and the type must be right,
This rabbit secured the uploads tonight! 🖼️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 18.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: auth-gated backend upload for issue-report screenshots as Phase 2a of storage hardening work.
Description check✅ PassedThe description is comprehensive and covers all key sections: what the changes do, specific backend/frontend/test changes, testing status, deployment sequence, and important scope clarifications about screenshot review.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/231-phase2a-issue-screenshot-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend500c025Commit Preview URL

Branch Preview URL
Jun 17 2026, 01:48 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/routes/feedback.py (1)

78-78: 💤 Low value

Dead-code fallback: file.content_type is already validated non-None.

Line 57 rejects requests where (file.content_type or "") is not in ALLOWED_SCREENSHOT_TYPES, so by the time line 78 executes, file.content_type is guaranteed to be a valid string from the allowlist. The or "application/octet-stream" fallback is unreachable.

🧹 Simplify to reflect the invariant
- "Content-Type": file.content_type or "application/octet-stream",+ "Content-Type": file.content_type,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 78, The `or "application/octet-stream"`
fallback in the Content-Type header assignment is unreachable dead code because
the validation at line 57 already ensures that `file.content_type` is a non-None
value from the ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator
and simplify the expression to use `file.content_type` directly, reflecting the
guaranteed invariant at that point in the code.
backend/tests/test_issue_screenshot_auth.py (1)

48-57: 💤 Low value

Test will break if the async httpx fix is applied.

If the endpoint is updated to use httpx.AsyncClient (as suggested for the blocking I/O issue), this mock will need adjustment since patch("routes.feedback.httpx") mocks the synchronous module-level usage.

For an AsyncClient refactor, you'd mock the async context manager and its methods. No action needed now, but keep this in mind when applying the async fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_issue_screenshot_auth.py` around lines 48 - 57, The
current test mocks the synchronous httpx module-level usage. When the endpoint
is refactored to use httpx.AsyncClient, this test will need to be updated to
properly mock the async context manager and its async methods. Specifically,
update the patch target from the module-level httpx to the AsyncClient class,
mock the async context manager's __aenter__ and __aexit__ methods, and ensure
the mocked put method is set up as a coroutine or async mock that can be awaited
within the async context. This adjustment will be necessary once the blocking
I/O issue is addressed and httpx.AsyncClient is introduced in the
routes.feedback module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/feedback.py`:
- Around line 72-81: Replace the synchronous `httpx.put()` call with an async
equivalent to prevent blocking the event loop. Use `httpx.AsyncClient()` with
`await` to make the call non-blocking, and add a timeout parameter to the
request to prevent indefinite hangs if Supabase Storage is unresponsive. You can
either create a new AsyncClient for each request or follow the module-level
client pattern (similar to the sync `_client` pattern mentioned in the comment)
by instantiating an async client at import time and reusing it across requests
for better performance.
---
Nitpick comments:
In `@backend/routes/feedback.py`:
- Line 78: The `or "application/octet-stream"` fallback in the Content-Type
header assignment is unreachable dead code because the validation at line 57
already ensures that `file.content_type` is a non-None value from the
ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator and simplify
the expression to use `file.content_type` directly, reflecting the guaranteed
invariant at that point in the code.
In `@backend/tests/test_issue_screenshot_auth.py`:
- Around line 48-57: The current test mocks the synchronous httpx module-level
usage. When the endpoint is refactored to use httpx.AsyncClient, this test will
need to be updated to properly mock the async context manager and its async
methods. Specifically, update the patch target from the module-level httpx to
the AsyncClient class, mock the async context manager's __aenter__ and __aexit__
methods, and ensure the mocked put method is set up as a coroutine or async mock
that can be awaited within the async context. This adjustment will be necessary
once the blocking I/O issue is addressed and httpx.AsyncClient is introduced in
the routes.feedback module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10aca5a2-e177-41c4-a2f0-6de68c683b63

📥 Commits

Reviewing files that changed from the base of the PR and between ee5b8f1 and 6d6226f.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx

Comment threadbackend/routes/feedback.py Outdated
Comment on lines +72 to +81
r = httpx.put(
url,
content=content,
headers={
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
"Content-Type": file.content_type or "application/octet-stream",
},
)
r.raise_for_status()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronous httpx.put blocks the async event loop.

httpx.put() is a synchronous call inside an async def function. Under concurrent load, each upload blocks the entire event loop until the Supabase round-trip completes, starving other requests.

Additionally, no timeout is specified—if Supabase Storage is slow or unresponsive, the call can hang indefinitely.

🔧 Proposed fix using async httpx with timeout
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- )- r.raise_for_status()+ async with httpx.AsyncClient(timeout=30.0) as http:+ r = await http.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type, # already validated non-None+ },+ )+ r.raise_for_status()

Alternatively, if you prefer to reuse a module-level async client (similar to the sync _client pattern in connection.py), you could instantiate one at import time and reuse it across requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 72 - 81, Replace the synchronous
`httpx.put()` call with an async equivalent to prevent blocking the event loop.
Use `httpx.AsyncClient()` with `await` to make the call non-blocking, and add a
timeout parameter to the request to prevent indefinite hangs if Supabase Storage
is unresponsive. You can either create a new AsyncClient for each request or
follow the module-level client pattern (similar to the sync `_client` pattern
mentioned in the comment) by instantiating an async client at import time and
reusing it across requests for better performance.

…g, timeout
Address PR #239 review:
- ReportIssueFlow.tsx: prefix screenshot upload fetch with API_URL (frontend
and backend are separate origins; bare path hit the frontend host).
- routes/feedback.py: replace r.raise_for_status() with the established
storage_service.upload_avatar pattern — check status, log, and raise
HTTPException(502) with a truncated upstream body (no URL/headers, which
carry the service-role key) instead of a generic 500.
- routes/feedback.py: add timeout=30.0 to the httpx.put (matches db client).
- test_issue_screenshot_auth.py: add Supabase-failure test asserting 502
(not 500) and that the key/URL don't leak; update the success-path mock to
set status_code=200 now that raise_for_status is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/feedback.py (3)

21-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Derive the object extension from the validated MIME type.

file.filename is user-controlled; copying its suffix can produce mismatched paths or URL-significant characters. Since the MIME type is already allowlisted, map it to a known extension.

🐛 Proposed fix
-ALLOWED_SCREENSHOT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}+SCREENSHOT_EXTENSIONS = {+ "image/png": "png",+ "image/jpeg": "jpg",+ "image/webp": "webp",+ "image/gif": "gif",+}+ALLOWED_SCREENSHOT_TYPES = set(SCREENSHOT_EXTENSIONS)
- if (file.content_type or "") not in ALLOWED_SCREENSHOT_TYPES:+ content_type = file.content_type or ""+ if content_type not in ALLOWED_SCREENSHOT_TYPES:
raise HTTPException(
status_code=415,
detail="Unsupported image type. Allowed: PNG, JPEG, WEBP, GIF.",
)
content = await read_within_limit(file, MAX_SCREENSHOT_BYTES) # 413 if oversize
if not content:
raise HTTPException(status_code=400, detail="Empty file.")
- ext = (- file.filename.rsplit(".", 1)[-1]- if file.filename and "." in file.filename- else "png"- )+ ext = SCREENSHOT_EXTENSIONS[content_type]

Also applies to: 60-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 21, The issue is that file.filename is
user-controlled and using its extension directly can produce mismatched file
paths or unexpected characters. Since the MIME type is already validated against
the ALLOWED_SCREENSHOT_TYPES allowlist, create a mapping from MIME types to
their corresponding file extensions (e.g., image/png to .png, image/jpeg to
.jpg, etc.). Then, wherever the file extension is used (in the code around lines
60-73 where file saving occurs), derive it from the validated MIME type using
this mapping instead of extracting it from the user-provided file.filename.

74-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep service-role storage writes behind the Supabase access boundary.

This route composes the Supabase Storage URL and sends SUPABASE_KEY directly from route code. Move the upload into the approved DB/storage boundary, or add an approved helper there, so service-role handling stays centralized.

As per coding guidelines, "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 74 - 83, The direct httpx.put call
to SUPABASE_URL with SUPABASE_KEY in the feedback route violates the approved
Supabase access pattern. Move the file upload logic (the httpx.put call that
constructs the Supabase Storage URL and sends the SUPABASE_KEY) from this route
into a dedicated helper function in backend/db/connection.py, similar to the
table() interface. Then replace the httpx.put code in the feedback route with a
call to this new helper function, ensuring all service-role storage operations
are centralized and go through the approved connection boundary.

Source: Coding guidelines


75-99: ⚠️ Potential issue | 🟠 Major

Map transport failures to 502 too.

timeout=30.0 now bounds the call, but timeout/connect/DNS failures raise before r exists, bypassing the status-code mapping and surfacing as 500s.

🐛 Proposed fix
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- timeout=30.0,- )+ try:+ r = httpx.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type or "application/octet-stream",+ },+ timeout=30.0,+ )+ except httpx.RequestError as exc:+ logger.warning(+ "upload_issue_screenshot: Supabase storage request failed "+ "user=%s error=%s",+ user_id,+ exc.__class__.__name__,+ )+ raise HTTPException(+ status_code=502,+ detail="Screenshot upload failed: storage service unavailable.",+ ) from exc
if r.status_code not in (200, 201):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 75 - 99, The current error handling
in the upload screenshot endpoint only catches HTTP status code failures from
the httpx.put call, but transport-level failures such as timeouts, connection
errors, or DNS failures will raise exceptions before the response object r is
created, bypassing the error mapping and surfacing as 500 errors instead of
502s. Wrap the httpx.put call in a try-except block to catch httpx transport
exceptions (such as TimeoutException, ConnectError, and similar) and raise the
same HTTPException with status_code 502, following the same pattern as the
existing status code check, to ensure all Supabase storage failures consistently
map to 502 responses.
frontend/src/components/ReportIssueFlow.tsx (1)

63-77: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mirror the backend screenshot MIME allowlist in the picker.

The UI accepts any image/*, but the backend only accepts PNG, JPEG, WEBP, and GIF. SVG/HEIC/BMP files pass client validation and then fail during submit with 415.

🐛 Proposed fix
 const MAX_SCREENSHOTS = 5;
const MAX_BYTES = 5 * 1024 * 1024;
+const ALLOWED_SCREENSHOT_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);+const ACCEPTED_SCREENSHOT_TYPES = "image/png,image/jpeg,image/webp,image/gif";
- if (!f.type.startsWith("image/")) {- toast.error(`${f.name} isn't an image`);+ if (!ALLOWED_SCREENSHOT_TYPES.has(f.type)) {+ toast.error(`${f.name} must be PNG, JPEG, WEBP, or GIF`);
return;
}
- accept="image/*"+ accept={ACCEPTED_SCREENSHOT_TYPES}

Also applies to: 240-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ReportIssueFlow.tsx` around lines 63 - 77, The
addFiles function currently validates image files too broadly by accepting any
f.type that starts with "image/", but the backend only accepts PNG, JPEG, WEBP,
and GIF formats. Update the MIME type validation check (currently checking
f.type.startsWith("image/")) to instead validate against a specific allowlist of
only the backend-supported formats: image/png, image/jpeg, image/webp, and
image/gif. This will prevent non-supported image types like SVG, HEIC, and BMP
from passing client validation and subsequently failing with a 415 error during
submission. Apply the same fix to the additional validation mentioned at lines
240-246.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@backend/routes/feedback.py`:
- Line 21: The issue is that file.filename is user-controlled and using its
extension directly can produce mismatched file paths or unexpected characters.
Since the MIME type is already validated against the ALLOWED_SCREENSHOT_TYPES
allowlist, create a mapping from MIME types to their corresponding file
extensions (e.g., image/png to .png, image/jpeg to .jpg, etc.). Then, wherever
the file extension is used (in the code around lines 60-73 where file saving
occurs), derive it from the validated MIME type using this mapping instead of
extracting it from the user-provided file.filename.
- Around line 74-83: The direct httpx.put call to SUPABASE_URL with SUPABASE_KEY
in the feedback route violates the approved Supabase access pattern. Move the
file upload logic (the httpx.put call that constructs the Supabase Storage URL
and sends the SUPABASE_KEY) from this route into a dedicated helper function in
backend/db/connection.py, similar to the table() interface. Then replace the
httpx.put code in the feedback route with a call to this new helper function,
ensuring all service-role storage operations are centralized and go through the
approved connection boundary.
- Around line 75-99: The current error handling in the upload screenshot
endpoint only catches HTTP status code failures from the httpx.put call, but
transport-level failures such as timeouts, connection errors, or DNS failures
will raise exceptions before the response object r is created, bypassing the
error mapping and surfacing as 500 errors instead of 502s. Wrap the httpx.put
call in a try-except block to catch httpx transport exceptions (such as
TimeoutException, ConnectError, and similar) and raise the same HTTPException
with status_code 502, following the same pattern as the existing status code
check, to ensure all Supabase storage failures consistently map to 502
responses.
In `@frontend/src/components/ReportIssueFlow.tsx`:
- Around line 63-77: The addFiles function currently validates image files too
broadly by accepting any f.type that starts with "image/", but the backend only
accepts PNG, JPEG, WEBP, and GIF formats. Update the MIME type validation check
(currently checking f.type.startsWith("image/")) to instead validate against a
specific allowlist of only the backend-supported formats: image/png, image/jpeg,
image/webp, and image/gif. This will prevent non-supported image types like SVG,
HEIC, and BMP from passing client validation and subsequently failing with a 415
error during submission. Apply the same fix to the additional validation
mentioned at lines 240-246.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8035ad70-4236-4eb7-afef-d338421399e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6226f and 1807a91.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_issue_screenshot_auth.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 8c71463 into mainJun 17, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the security/231-phase2a-issue-screenshot-backend branch June 17, 2026 01:49
Sign up for freeto 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

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a) - #239

Merged
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend
Jun 17, 2026
Merged

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a)#239
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 15, 2026

Copy link
Copy Markdown
Member

Phase 2a of #231 storage hardening — the app change that must deploy before flipping issues-media-files private (2b).

What

  • BackendPOST /api/issue-reports/screenshot: get_session_user_id (401 unauth) + content-type allowlist (415) + bounded read (413, request_limits.read_within_limit), uploads with the service role, returns the storage path.
  • FrontendReportIssueFlow: uploads via the endpoint (credentials:'include'), stores the path; drops the supabase anon storage client — removing the last live frontend anon-storage path.
  • Testtest_issue_screenshot_auth.py: 401 / 415 / 413 / 200(path scoped to user). All fail pre-fix (endpoint didn't exist).

⚠️ Scope correction worth your eye

While building, I confirmed there is no in-app reader for issue screenshotsfeedback.py only INSERTs, no GET/admin view, nothing renders screenshot_urls. It's write-only collected, exactly like résumés. So I did not build a signed-URL read endpoint: it would have no consumer (dead code). Review is via the Supabase dashboard (works on private buckets).

That changes the gate you set: there are no in-app screenshots to "render," so the pre-2b check isn't "confirm screenshots render" — it's "confirm a new issue-report submission with a screenshot succeeds via the backend in prod." If you'd rather have an in-app admin review view now (which would give the signed-URL read a real consumer), say so and I'll add it; otherwise this matches the résumé decision (dashboard review, deferred admin view).

Sequence

  1. Merge + deploy 2a (backend + frontend) to prod.
  2. Verify a new issue report with a screenshot submits cleanly (backend upload path).
  3. Then apply 2b (flip issues-media-files private + drop its two {public} policies). Existing 2 rows' public URLs stop resolving → dashboard review (same as résumés).

⚠️ Do not merge/deploy until reviewed.

Summary by CodeRabbit

  • New Features
    • Added authenticated screenshot uploads for issue reports, including file-type allowlisting and a 5MB maximum size limit.
    • Updated the issue reporting flow to upload screenshots through the backend and use the returned storage path; in local mode, it also provides a preview.
  • Tests
    • Added regression coverage for authentication enforcement, MIME/type and size validation, successful uploads, and backend upload failures.

…ackend upload (#231 Phase 2a)
Moves issue-report screenshot uploads off the frontend's public anon-key storage
client onto an auth-gated, service-role backend endpoint so issues-media-files
can be made private (Phase 2b).
- Backend: POST /api/issue-reports/screenshot — get_session_user_id (401),
content-type allowlist (415) + bounded read (413) via request_limits, uploads
with the service role, returns the storage PATH.
- Frontend: ReportIssueFlow uploads via the endpoint (credentials:include) and
stores the path; drops the supabase anon storage client (removes the last live
frontend anon-storage path).
- Test: unauth 401, wrong-type 415, oversize 413, valid 200 (path scoped to the
authed user). All fail pre-fix (endpoint didn't exist).
Note: issue screenshots have NO in-app reader (write-only, like résumés), so no
signed-URL read endpoint is included — it would have no consumer; review is via
the dashboard. screenshot_urls now stores paths for new reports.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ef35458-80a2-4924-8f70-9300932d576f

📥 Commits

Reviewing files that changed from the base of the PR and between 1807a91 and 500c025.

📒 Files selected for processing (1)
  • backend/routes/feedback.py
📝 Walkthrough

Walkthrough

Moves issue-report screenshot uploads from direct Supabase client-side uploads to a new authenticated server-side FastAPI endpoint (POST /issue-reports/screenshot). The backend validates MIME type, enforces a 5MB size limit, and uploads using the service role key. The frontend uploadScreenshot helper is updated to call this endpoint instead.

Changes

Screenshot Upload via Backend Endpoint

Layer / File(s)Summary
Backend screenshot upload endpoint
backend/routes/feedback.py
Adds ISSUE_BUCKET, MAX_SCREENSHOT_BYTES, ALLOWED_SCREENSHOT_TYPES constants and the authenticated POST /issue-reports/screenshot handler: validates content type (415), enforces size limit (413), rejects empty bodies (400), constructs a user-scoped storage path, uploads bytes to Supabase via service role credentials, and returns the storage path.
Frontend uploadScreenshot refactor
frontend/src/components/ReportIssueFlow.tsx
Removes direct Supabase client upload logic (supabase import, BUCKET, userId-based path). Replaces with a fetch call to /api/issue-reports/screenshot, returns the backend-provided path, and drops userId from the submit call site. Local-mode blob URL behavior is preserved.
Endpoint regression tests
backend/tests/test_issue_screenshot_auth.py
Adds TestIssueScreenshotUpload with cases covering 401 on unauthenticated access, 415 for wrong MIME type (no outbound upload), 413 for oversized payload (no outbound upload), 200 with user-scoped .png path for a valid upload (exactly one httpx.put call), and 502 for upstream failures while preventing sensitive header/URL leakage.

Sequence Diagram

sequenceDiagram
participant Browser as Browser (ReportIssueFlow)
participant API as FastAPI /issue-reports/screenshot
participant Supabase as Supabase Storage
Browser->>API: POST /api/issue-reports/screenshot (UploadFile + session cookie)
API->>API: decode session → user_id (401 if missing)
API->>API: check content_type vs allowlist (415 on mismatch)
API->>API: read body ≤ 5 MB (413 on oversize, 400 if empty)
API->>Supabase: PUT {user_id}/screenshots/{uuid}.{ext} with service role key
Supabase-->>API: 200 OK
API-->>Browser: {"path": "{user_id}/screenshots/{uuid}.{ext}"}
Browser->>Browser: use path as screenshot_urls in issue report submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A screenshot once flew straight to the cloud,
Now hops through the backend — auth-checked and proud.
The bucket sits safe behind token and gate,
No direct upload shall bypass our state.
Five megabytes max, and the type must be right,
This rabbit secured the uploads tonight! 🖼️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 18.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: auth-gated backend upload for issue-report screenshots as Phase 2a of storage hardening work.
Description check✅ PassedThe description is comprehensive and covers all key sections: what the changes do, specific backend/frontend/test changes, testing status, deployment sequence, and important scope clarifications about screenshot review.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/231-phase2a-issue-screenshot-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend500c025Commit Preview URL

Branch Preview URL
Jun 17 2026, 01:48 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/routes/feedback.py (1)

78-78: 💤 Low value

Dead-code fallback: file.content_type is already validated non-None.

Line 57 rejects requests where (file.content_type or "") is not in ALLOWED_SCREENSHOT_TYPES, so by the time line 78 executes, file.content_type is guaranteed to be a valid string from the allowlist. The or "application/octet-stream" fallback is unreachable.

🧹 Simplify to reflect the invariant
- "Content-Type": file.content_type or "application/octet-stream",+ "Content-Type": file.content_type,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 78, The `or "application/octet-stream"`
fallback in the Content-Type header assignment is unreachable dead code because
the validation at line 57 already ensures that `file.content_type` is a non-None
value from the ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator
and simplify the expression to use `file.content_type` directly, reflecting the
guaranteed invariant at that point in the code.
backend/tests/test_issue_screenshot_auth.py (1)

48-57: 💤 Low value

Test will break if the async httpx fix is applied.

If the endpoint is updated to use httpx.AsyncClient (as suggested for the blocking I/O issue), this mock will need adjustment since patch("routes.feedback.httpx") mocks the synchronous module-level usage.

For an AsyncClient refactor, you'd mock the async context manager and its methods. No action needed now, but keep this in mind when applying the async fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_issue_screenshot_auth.py` around lines 48 - 57, The
current test mocks the synchronous httpx module-level usage. When the endpoint
is refactored to use httpx.AsyncClient, this test will need to be updated to
properly mock the async context manager and its async methods. Specifically,
update the patch target from the module-level httpx to the AsyncClient class,
mock the async context manager's __aenter__ and __aexit__ methods, and ensure
the mocked put method is set up as a coroutine or async mock that can be awaited
within the async context. This adjustment will be necessary once the blocking
I/O issue is addressed and httpx.AsyncClient is introduced in the
routes.feedback module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/feedback.py`:
- Around line 72-81: Replace the synchronous `httpx.put()` call with an async
equivalent to prevent blocking the event loop. Use `httpx.AsyncClient()` with
`await` to make the call non-blocking, and add a timeout parameter to the
request to prevent indefinite hangs if Supabase Storage is unresponsive. You can
either create a new AsyncClient for each request or follow the module-level
client pattern (similar to the sync `_client` pattern mentioned in the comment)
by instantiating an async client at import time and reusing it across requests
for better performance.
---
Nitpick comments:
In `@backend/routes/feedback.py`:
- Line 78: The `or "application/octet-stream"` fallback in the Content-Type
header assignment is unreachable dead code because the validation at line 57
already ensures that `file.content_type` is a non-None value from the
ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator and simplify
the expression to use `file.content_type` directly, reflecting the guaranteed
invariant at that point in the code.
In `@backend/tests/test_issue_screenshot_auth.py`:
- Around line 48-57: The current test mocks the synchronous httpx module-level
usage. When the endpoint is refactored to use httpx.AsyncClient, this test will
need to be updated to properly mock the async context manager and its async
methods. Specifically, update the patch target from the module-level httpx to
the AsyncClient class, mock the async context manager's __aenter__ and __aexit__
methods, and ensure the mocked put method is set up as a coroutine or async mock
that can be awaited within the async context. This adjustment will be necessary
once the blocking I/O issue is addressed and httpx.AsyncClient is introduced in
the routes.feedback module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10aca5a2-e177-41c4-a2f0-6de68c683b63

📥 Commits

Reviewing files that changed from the base of the PR and between ee5b8f1 and 6d6226f.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx

Comment threadbackend/routes/feedback.py Outdated
Comment on lines +72 to +81
r = httpx.put(
url,
content=content,
headers={
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
"Content-Type": file.content_type or "application/octet-stream",
},
)
r.raise_for_status()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronous httpx.put blocks the async event loop.

httpx.put() is a synchronous call inside an async def function. Under concurrent load, each upload blocks the entire event loop until the Supabase round-trip completes, starving other requests.

Additionally, no timeout is specified—if Supabase Storage is slow or unresponsive, the call can hang indefinitely.

🔧 Proposed fix using async httpx with timeout
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- )- r.raise_for_status()+ async with httpx.AsyncClient(timeout=30.0) as http:+ r = await http.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type, # already validated non-None+ },+ )+ r.raise_for_status()

Alternatively, if you prefer to reuse a module-level async client (similar to the sync _client pattern in connection.py), you could instantiate one at import time and reuse it across requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 72 - 81, Replace the synchronous
`httpx.put()` call with an async equivalent to prevent blocking the event loop.
Use `httpx.AsyncClient()` with `await` to make the call non-blocking, and add a
timeout parameter to the request to prevent indefinite hangs if Supabase Storage
is unresponsive. You can either create a new AsyncClient for each request or
follow the module-level client pattern (similar to the sync `_client` pattern
mentioned in the comment) by instantiating an async client at import time and
reusing it across requests for better performance.

…g, timeout
Address PR #239 review:
- ReportIssueFlow.tsx: prefix screenshot upload fetch with API_URL (frontend
and backend are separate origins; bare path hit the frontend host).
- routes/feedback.py: replace r.raise_for_status() with the established
storage_service.upload_avatar pattern — check status, log, and raise
HTTPException(502) with a truncated upstream body (no URL/headers, which
carry the service-role key) instead of a generic 500.
- routes/feedback.py: add timeout=30.0 to the httpx.put (matches db client).
- test_issue_screenshot_auth.py: add Supabase-failure test asserting 502
(not 500) and that the key/URL don't leak; update the success-path mock to
set status_code=200 now that raise_for_status is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/feedback.py (3)

21-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Derive the object extension from the validated MIME type.

file.filename is user-controlled; copying its suffix can produce mismatched paths or URL-significant characters. Since the MIME type is already allowlisted, map it to a known extension.

🐛 Proposed fix
-ALLOWED_SCREENSHOT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}+SCREENSHOT_EXTENSIONS = {+ "image/png": "png",+ "image/jpeg": "jpg",+ "image/webp": "webp",+ "image/gif": "gif",+}+ALLOWED_SCREENSHOT_TYPES = set(SCREENSHOT_EXTENSIONS)
- if (file.content_type or "") not in ALLOWED_SCREENSHOT_TYPES:+ content_type = file.content_type or ""+ if content_type not in ALLOWED_SCREENSHOT_TYPES:
raise HTTPException(
status_code=415,
detail="Unsupported image type. Allowed: PNG, JPEG, WEBP, GIF.",
)
content = await read_within_limit(file, MAX_SCREENSHOT_BYTES) # 413 if oversize
if not content:
raise HTTPException(status_code=400, detail="Empty file.")
- ext = (- file.filename.rsplit(".", 1)[-1]- if file.filename and "." in file.filename- else "png"- )+ ext = SCREENSHOT_EXTENSIONS[content_type]

Also applies to: 60-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 21, The issue is that file.filename is
user-controlled and using its extension directly can produce mismatched file
paths or unexpected characters. Since the MIME type is already validated against
the ALLOWED_SCREENSHOT_TYPES allowlist, create a mapping from MIME types to
their corresponding file extensions (e.g., image/png to .png, image/jpeg to
.jpg, etc.). Then, wherever the file extension is used (in the code around lines
60-73 where file saving occurs), derive it from the validated MIME type using
this mapping instead of extracting it from the user-provided file.filename.

74-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep service-role storage writes behind the Supabase access boundary.

This route composes the Supabase Storage URL and sends SUPABASE_KEY directly from route code. Move the upload into the approved DB/storage boundary, or add an approved helper there, so service-role handling stays centralized.

As per coding guidelines, "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 74 - 83, The direct httpx.put call
to SUPABASE_URL with SUPABASE_KEY in the feedback route violates the approved
Supabase access pattern. Move the file upload logic (the httpx.put call that
constructs the Supabase Storage URL and sends the SUPABASE_KEY) from this route
into a dedicated helper function in backend/db/connection.py, similar to the
table() interface. Then replace the httpx.put code in the feedback route with a
call to this new helper function, ensuring all service-role storage operations
are centralized and go through the approved connection boundary.

Source: Coding guidelines


75-99: ⚠️ Potential issue | 🟠 Major

Map transport failures to 502 too.

timeout=30.0 now bounds the call, but timeout/connect/DNS failures raise before r exists, bypassing the status-code mapping and surfacing as 500s.

🐛 Proposed fix
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- timeout=30.0,- )+ try:+ r = httpx.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type or "application/octet-stream",+ },+ timeout=30.0,+ )+ except httpx.RequestError as exc:+ logger.warning(+ "upload_issue_screenshot: Supabase storage request failed "+ "user=%s error=%s",+ user_id,+ exc.__class__.__name__,+ )+ raise HTTPException(+ status_code=502,+ detail="Screenshot upload failed: storage service unavailable.",+ ) from exc
if r.status_code not in (200, 201):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 75 - 99, The current error handling
in the upload screenshot endpoint only catches HTTP status code failures from
the httpx.put call, but transport-level failures such as timeouts, connection
errors, or DNS failures will raise exceptions before the response object r is
created, bypassing the error mapping and surfacing as 500 errors instead of
502s. Wrap the httpx.put call in a try-except block to catch httpx transport
exceptions (such as TimeoutException, ConnectError, and similar) and raise the
same HTTPException with status_code 502, following the same pattern as the
existing status code check, to ensure all Supabase storage failures consistently
map to 502 responses.
frontend/src/components/ReportIssueFlow.tsx (1)

63-77: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mirror the backend screenshot MIME allowlist in the picker.

The UI accepts any image/*, but the backend only accepts PNG, JPEG, WEBP, and GIF. SVG/HEIC/BMP files pass client validation and then fail during submit with 415.

🐛 Proposed fix
 const MAX_SCREENSHOTS = 5;
const MAX_BYTES = 5 * 1024 * 1024;
+const ALLOWED_SCREENSHOT_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);+const ACCEPTED_SCREENSHOT_TYPES = "image/png,image/jpeg,image/webp,image/gif";
- if (!f.type.startsWith("image/")) {- toast.error(`${f.name} isn't an image`);+ if (!ALLOWED_SCREENSHOT_TYPES.has(f.type)) {+ toast.error(`${f.name} must be PNG, JPEG, WEBP, or GIF`);
return;
}
- accept="image/*"+ accept={ACCEPTED_SCREENSHOT_TYPES}

Also applies to: 240-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ReportIssueFlow.tsx` around lines 63 - 77, The
addFiles function currently validates image files too broadly by accepting any
f.type that starts with "image/", but the backend only accepts PNG, JPEG, WEBP,
and GIF formats. Update the MIME type validation check (currently checking
f.type.startsWith("image/")) to instead validate against a specific allowlist of
only the backend-supported formats: image/png, image/jpeg, image/webp, and
image/gif. This will prevent non-supported image types like SVG, HEIC, and BMP
from passing client validation and subsequently failing with a 415 error during
submission. Apply the same fix to the additional validation mentioned at lines
240-246.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@backend/routes/feedback.py`:
- Line 21: The issue is that file.filename is user-controlled and using its
extension directly can produce mismatched file paths or unexpected characters.
Since the MIME type is already validated against the ALLOWED_SCREENSHOT_TYPES
allowlist, create a mapping from MIME types to their corresponding file
extensions (e.g., image/png to .png, image/jpeg to .jpg, etc.). Then, wherever
the file extension is used (in the code around lines 60-73 where file saving
occurs), derive it from the validated MIME type using this mapping instead of
extracting it from the user-provided file.filename.
- Around line 74-83: The direct httpx.put call to SUPABASE_URL with SUPABASE_KEY
in the feedback route violates the approved Supabase access pattern. Move the
file upload logic (the httpx.put call that constructs the Supabase Storage URL
and sends the SUPABASE_KEY) from this route into a dedicated helper function in
backend/db/connection.py, similar to the table() interface. Then replace the
httpx.put code in the feedback route with a call to this new helper function,
ensuring all service-role storage operations are centralized and go through the
approved connection boundary.
- Around line 75-99: The current error handling in the upload screenshot
endpoint only catches HTTP status code failures from the httpx.put call, but
transport-level failures such as timeouts, connection errors, or DNS failures
will raise exceptions before the response object r is created, bypassing the
error mapping and surfacing as 500 errors instead of 502s. Wrap the httpx.put
call in a try-except block to catch httpx transport exceptions (such as
TimeoutException, ConnectError, and similar) and raise the same HTTPException
with status_code 502, following the same pattern as the existing status code
check, to ensure all Supabase storage failures consistently map to 502
responses.
In `@frontend/src/components/ReportIssueFlow.tsx`:
- Around line 63-77: The addFiles function currently validates image files too
broadly by accepting any f.type that starts with "image/", but the backend only
accepts PNG, JPEG, WEBP, and GIF formats. Update the MIME type validation check
(currently checking f.type.startsWith("image/")) to instead validate against a
specific allowlist of only the backend-supported formats: image/png, image/jpeg,
image/webp, and image/gif. This will prevent non-supported image types like SVG,
HEIC, and BMP from passing client validation and subsequently failing with a 415
error during submission. Apply the same fix to the additional validation
mentioned at lines 240-246.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8035ad70-4236-4eb7-afef-d338421399e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6226f and 1807a91.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_issue_screenshot_auth.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 8c71463 into mainJun 17, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the security/231-phase2a-issue-screenshot-backend branch June 17, 2026 01:49
Sign up for freeto 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

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a) - #239

Merged
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend
Jun 17, 2026
Merged

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a)#239
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 15, 2026

Copy link
Copy Markdown
Member

Phase 2a of #231 storage hardening — the app change that must deploy before flipping issues-media-files private (2b).

What

  • BackendPOST /api/issue-reports/screenshot: get_session_user_id (401 unauth) + content-type allowlist (415) + bounded read (413, request_limits.read_within_limit), uploads with the service role, returns the storage path.
  • FrontendReportIssueFlow: uploads via the endpoint (credentials:'include'), stores the path; drops the supabase anon storage client — removing the last live frontend anon-storage path.
  • Testtest_issue_screenshot_auth.py: 401 / 415 / 413 / 200(path scoped to user). All fail pre-fix (endpoint didn't exist).

⚠️ Scope correction worth your eye

While building, I confirmed there is no in-app reader for issue screenshotsfeedback.py only INSERTs, no GET/admin view, nothing renders screenshot_urls. It's write-only collected, exactly like résumés. So I did not build a signed-URL read endpoint: it would have no consumer (dead code). Review is via the Supabase dashboard (works on private buckets).

That changes the gate you set: there are no in-app screenshots to "render," so the pre-2b check isn't "confirm screenshots render" — it's "confirm a new issue-report submission with a screenshot succeeds via the backend in prod." If you'd rather have an in-app admin review view now (which would give the signed-URL read a real consumer), say so and I'll add it; otherwise this matches the résumé decision (dashboard review, deferred admin view).

Sequence

  1. Merge + deploy 2a (backend + frontend) to prod.
  2. Verify a new issue report with a screenshot submits cleanly (backend upload path).
  3. Then apply 2b (flip issues-media-files private + drop its two {public} policies). Existing 2 rows' public URLs stop resolving → dashboard review (same as résumés).

⚠️ Do not merge/deploy until reviewed.

Summary by CodeRabbit

  • New Features
    • Added authenticated screenshot uploads for issue reports, including file-type allowlisting and a 5MB maximum size limit.
    • Updated the issue reporting flow to upload screenshots through the backend and use the returned storage path; in local mode, it also provides a preview.
  • Tests
    • Added regression coverage for authentication enforcement, MIME/type and size validation, successful uploads, and backend upload failures.

…ackend upload (#231 Phase 2a)
Moves issue-report screenshot uploads off the frontend's public anon-key storage
client onto an auth-gated, service-role backend endpoint so issues-media-files
can be made private (Phase 2b).
- Backend: POST /api/issue-reports/screenshot — get_session_user_id (401),
content-type allowlist (415) + bounded read (413) via request_limits, uploads
with the service role, returns the storage PATH.
- Frontend: ReportIssueFlow uploads via the endpoint (credentials:include) and
stores the path; drops the supabase anon storage client (removes the last live
frontend anon-storage path).
- Test: unauth 401, wrong-type 415, oversize 413, valid 200 (path scoped to the
authed user). All fail pre-fix (endpoint didn't exist).
Note: issue screenshots have NO in-app reader (write-only, like résumés), so no
signed-URL read endpoint is included — it would have no consumer; review is via
the dashboard. screenshot_urls now stores paths for new reports.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ef35458-80a2-4924-8f70-9300932d576f

📥 Commits

Reviewing files that changed from the base of the PR and between 1807a91 and 500c025.

📒 Files selected for processing (1)
  • backend/routes/feedback.py
📝 Walkthrough

Walkthrough

Moves issue-report screenshot uploads from direct Supabase client-side uploads to a new authenticated server-side FastAPI endpoint (POST /issue-reports/screenshot). The backend validates MIME type, enforces a 5MB size limit, and uploads using the service role key. The frontend uploadScreenshot helper is updated to call this endpoint instead.

Changes

Screenshot Upload via Backend Endpoint

Layer / File(s)Summary
Backend screenshot upload endpoint
backend/routes/feedback.py
Adds ISSUE_BUCKET, MAX_SCREENSHOT_BYTES, ALLOWED_SCREENSHOT_TYPES constants and the authenticated POST /issue-reports/screenshot handler: validates content type (415), enforces size limit (413), rejects empty bodies (400), constructs a user-scoped storage path, uploads bytes to Supabase via service role credentials, and returns the storage path.
Frontend uploadScreenshot refactor
frontend/src/components/ReportIssueFlow.tsx
Removes direct Supabase client upload logic (supabase import, BUCKET, userId-based path). Replaces with a fetch call to /api/issue-reports/screenshot, returns the backend-provided path, and drops userId from the submit call site. Local-mode blob URL behavior is preserved.
Endpoint regression tests
backend/tests/test_issue_screenshot_auth.py
Adds TestIssueScreenshotUpload with cases covering 401 on unauthenticated access, 415 for wrong MIME type (no outbound upload), 413 for oversized payload (no outbound upload), 200 with user-scoped .png path for a valid upload (exactly one httpx.put call), and 502 for upstream failures while preventing sensitive header/URL leakage.

Sequence Diagram

sequenceDiagram
participant Browser as Browser (ReportIssueFlow)
participant API as FastAPI /issue-reports/screenshot
participant Supabase as Supabase Storage
Browser->>API: POST /api/issue-reports/screenshot (UploadFile + session cookie)
API->>API: decode session → user_id (401 if missing)
API->>API: check content_type vs allowlist (415 on mismatch)
API->>API: read body ≤ 5 MB (413 on oversize, 400 if empty)
API->>Supabase: PUT {user_id}/screenshots/{uuid}.{ext} with service role key
Supabase-->>API: 200 OK
API-->>Browser: {"path": "{user_id}/screenshots/{uuid}.{ext}"}
Browser->>Browser: use path as screenshot_urls in issue report submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A screenshot once flew straight to the cloud,
Now hops through the backend — auth-checked and proud.
The bucket sits safe behind token and gate,
No direct upload shall bypass our state.
Five megabytes max, and the type must be right,
This rabbit secured the uploads tonight! 🖼️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 18.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: auth-gated backend upload for issue-report screenshots as Phase 2a of storage hardening work.
Description check✅ PassedThe description is comprehensive and covers all key sections: what the changes do, specific backend/frontend/test changes, testing status, deployment sequence, and important scope clarifications about screenshot review.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/231-phase2a-issue-screenshot-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend500c025Commit Preview URL

Branch Preview URL
Jun 17 2026, 01:48 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/routes/feedback.py (1)

78-78: 💤 Low value

Dead-code fallback: file.content_type is already validated non-None.

Line 57 rejects requests where (file.content_type or "") is not in ALLOWED_SCREENSHOT_TYPES, so by the time line 78 executes, file.content_type is guaranteed to be a valid string from the allowlist. The or "application/octet-stream" fallback is unreachable.

🧹 Simplify to reflect the invariant
- "Content-Type": file.content_type or "application/octet-stream",+ "Content-Type": file.content_type,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 78, The `or "application/octet-stream"`
fallback in the Content-Type header assignment is unreachable dead code because
the validation at line 57 already ensures that `file.content_type` is a non-None
value from the ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator
and simplify the expression to use `file.content_type` directly, reflecting the
guaranteed invariant at that point in the code.
backend/tests/test_issue_screenshot_auth.py (1)

48-57: 💤 Low value

Test will break if the async httpx fix is applied.

If the endpoint is updated to use httpx.AsyncClient (as suggested for the blocking I/O issue), this mock will need adjustment since patch("routes.feedback.httpx") mocks the synchronous module-level usage.

For an AsyncClient refactor, you'd mock the async context manager and its methods. No action needed now, but keep this in mind when applying the async fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_issue_screenshot_auth.py` around lines 48 - 57, The
current test mocks the synchronous httpx module-level usage. When the endpoint
is refactored to use httpx.AsyncClient, this test will need to be updated to
properly mock the async context manager and its async methods. Specifically,
update the patch target from the module-level httpx to the AsyncClient class,
mock the async context manager's __aenter__ and __aexit__ methods, and ensure
the mocked put method is set up as a coroutine or async mock that can be awaited
within the async context. This adjustment will be necessary once the blocking
I/O issue is addressed and httpx.AsyncClient is introduced in the
routes.feedback module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/feedback.py`:
- Around line 72-81: Replace the synchronous `httpx.put()` call with an async
equivalent to prevent blocking the event loop. Use `httpx.AsyncClient()` with
`await` to make the call non-blocking, and add a timeout parameter to the
request to prevent indefinite hangs if Supabase Storage is unresponsive. You can
either create a new AsyncClient for each request or follow the module-level
client pattern (similar to the sync `_client` pattern mentioned in the comment)
by instantiating an async client at import time and reusing it across requests
for better performance.
---
Nitpick comments:
In `@backend/routes/feedback.py`:
- Line 78: The `or "application/octet-stream"` fallback in the Content-Type
header assignment is unreachable dead code because the validation at line 57
already ensures that `file.content_type` is a non-None value from the
ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator and simplify
the expression to use `file.content_type` directly, reflecting the guaranteed
invariant at that point in the code.
In `@backend/tests/test_issue_screenshot_auth.py`:
- Around line 48-57: The current test mocks the synchronous httpx module-level
usage. When the endpoint is refactored to use httpx.AsyncClient, this test will
need to be updated to properly mock the async context manager and its async
methods. Specifically, update the patch target from the module-level httpx to
the AsyncClient class, mock the async context manager's __aenter__ and __aexit__
methods, and ensure the mocked put method is set up as a coroutine or async mock
that can be awaited within the async context. This adjustment will be necessary
once the blocking I/O issue is addressed and httpx.AsyncClient is introduced in
the routes.feedback module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10aca5a2-e177-41c4-a2f0-6de68c683b63

📥 Commits

Reviewing files that changed from the base of the PR and between ee5b8f1 and 6d6226f.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx

Comment threadbackend/routes/feedback.py Outdated
Comment on lines +72 to +81
r = httpx.put(
url,
content=content,
headers={
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
"Content-Type": file.content_type or "application/octet-stream",
},
)
r.raise_for_status()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronous httpx.put blocks the async event loop.

httpx.put() is a synchronous call inside an async def function. Under concurrent load, each upload blocks the entire event loop until the Supabase round-trip completes, starving other requests.

Additionally, no timeout is specified—if Supabase Storage is slow or unresponsive, the call can hang indefinitely.

🔧 Proposed fix using async httpx with timeout
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- )- r.raise_for_status()+ async with httpx.AsyncClient(timeout=30.0) as http:+ r = await http.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type, # already validated non-None+ },+ )+ r.raise_for_status()

Alternatively, if you prefer to reuse a module-level async client (similar to the sync _client pattern in connection.py), you could instantiate one at import time and reuse it across requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 72 - 81, Replace the synchronous
`httpx.put()` call with an async equivalent to prevent blocking the event loop.
Use `httpx.AsyncClient()` with `await` to make the call non-blocking, and add a
timeout parameter to the request to prevent indefinite hangs if Supabase Storage
is unresponsive. You can either create a new AsyncClient for each request or
follow the module-level client pattern (similar to the sync `_client` pattern
mentioned in the comment) by instantiating an async client at import time and
reusing it across requests for better performance.

…g, timeout
Address PR #239 review:
- ReportIssueFlow.tsx: prefix screenshot upload fetch with API_URL (frontend
and backend are separate origins; bare path hit the frontend host).
- routes/feedback.py: replace r.raise_for_status() with the established
storage_service.upload_avatar pattern — check status, log, and raise
HTTPException(502) with a truncated upstream body (no URL/headers, which
carry the service-role key) instead of a generic 500.
- routes/feedback.py: add timeout=30.0 to the httpx.put (matches db client).
- test_issue_screenshot_auth.py: add Supabase-failure test asserting 502
(not 500) and that the key/URL don't leak; update the success-path mock to
set status_code=200 now that raise_for_status is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/feedback.py (3)

21-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Derive the object extension from the validated MIME type.

file.filename is user-controlled; copying its suffix can produce mismatched paths or URL-significant characters. Since the MIME type is already allowlisted, map it to a known extension.

🐛 Proposed fix
-ALLOWED_SCREENSHOT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}+SCREENSHOT_EXTENSIONS = {+ "image/png": "png",+ "image/jpeg": "jpg",+ "image/webp": "webp",+ "image/gif": "gif",+}+ALLOWED_SCREENSHOT_TYPES = set(SCREENSHOT_EXTENSIONS)
- if (file.content_type or "") not in ALLOWED_SCREENSHOT_TYPES:+ content_type = file.content_type or ""+ if content_type not in ALLOWED_SCREENSHOT_TYPES:
raise HTTPException(
status_code=415,
detail="Unsupported image type. Allowed: PNG, JPEG, WEBP, GIF.",
)
content = await read_within_limit(file, MAX_SCREENSHOT_BYTES) # 413 if oversize
if not content:
raise HTTPException(status_code=400, detail="Empty file.")
- ext = (- file.filename.rsplit(".", 1)[-1]- if file.filename and "." in file.filename- else "png"- )+ ext = SCREENSHOT_EXTENSIONS[content_type]

Also applies to: 60-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 21, The issue is that file.filename is
user-controlled and using its extension directly can produce mismatched file
paths or unexpected characters. Since the MIME type is already validated against
the ALLOWED_SCREENSHOT_TYPES allowlist, create a mapping from MIME types to
their corresponding file extensions (e.g., image/png to .png, image/jpeg to
.jpg, etc.). Then, wherever the file extension is used (in the code around lines
60-73 where file saving occurs), derive it from the validated MIME type using
this mapping instead of extracting it from the user-provided file.filename.

74-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep service-role storage writes behind the Supabase access boundary.

This route composes the Supabase Storage URL and sends SUPABASE_KEY directly from route code. Move the upload into the approved DB/storage boundary, or add an approved helper there, so service-role handling stays centralized.

As per coding guidelines, "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 74 - 83, The direct httpx.put call
to SUPABASE_URL with SUPABASE_KEY in the feedback route violates the approved
Supabase access pattern. Move the file upload logic (the httpx.put call that
constructs the Supabase Storage URL and sends the SUPABASE_KEY) from this route
into a dedicated helper function in backend/db/connection.py, similar to the
table() interface. Then replace the httpx.put code in the feedback route with a
call to this new helper function, ensuring all service-role storage operations
are centralized and go through the approved connection boundary.

Source: Coding guidelines


75-99: ⚠️ Potential issue | 🟠 Major

Map transport failures to 502 too.

timeout=30.0 now bounds the call, but timeout/connect/DNS failures raise before r exists, bypassing the status-code mapping and surfacing as 500s.

🐛 Proposed fix
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- timeout=30.0,- )+ try:+ r = httpx.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type or "application/octet-stream",+ },+ timeout=30.0,+ )+ except httpx.RequestError as exc:+ logger.warning(+ "upload_issue_screenshot: Supabase storage request failed "+ "user=%s error=%s",+ user_id,+ exc.__class__.__name__,+ )+ raise HTTPException(+ status_code=502,+ detail="Screenshot upload failed: storage service unavailable.",+ ) from exc
if r.status_code not in (200, 201):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 75 - 99, The current error handling
in the upload screenshot endpoint only catches HTTP status code failures from
the httpx.put call, but transport-level failures such as timeouts, connection
errors, or DNS failures will raise exceptions before the response object r is
created, bypassing the error mapping and surfacing as 500 errors instead of
502s. Wrap the httpx.put call in a try-except block to catch httpx transport
exceptions (such as TimeoutException, ConnectError, and similar) and raise the
same HTTPException with status_code 502, following the same pattern as the
existing status code check, to ensure all Supabase storage failures consistently
map to 502 responses.
frontend/src/components/ReportIssueFlow.tsx (1)

63-77: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mirror the backend screenshot MIME allowlist in the picker.

The UI accepts any image/*, but the backend only accepts PNG, JPEG, WEBP, and GIF. SVG/HEIC/BMP files pass client validation and then fail during submit with 415.

🐛 Proposed fix
 const MAX_SCREENSHOTS = 5;
const MAX_BYTES = 5 * 1024 * 1024;
+const ALLOWED_SCREENSHOT_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);+const ACCEPTED_SCREENSHOT_TYPES = "image/png,image/jpeg,image/webp,image/gif";
- if (!f.type.startsWith("image/")) {- toast.error(`${f.name} isn't an image`);+ if (!ALLOWED_SCREENSHOT_TYPES.has(f.type)) {+ toast.error(`${f.name} must be PNG, JPEG, WEBP, or GIF`);
return;
}
- accept="image/*"+ accept={ACCEPTED_SCREENSHOT_TYPES}

Also applies to: 240-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ReportIssueFlow.tsx` around lines 63 - 77, The
addFiles function currently validates image files too broadly by accepting any
f.type that starts with "image/", but the backend only accepts PNG, JPEG, WEBP,
and GIF formats. Update the MIME type validation check (currently checking
f.type.startsWith("image/")) to instead validate against a specific allowlist of
only the backend-supported formats: image/png, image/jpeg, image/webp, and
image/gif. This will prevent non-supported image types like SVG, HEIC, and BMP
from passing client validation and subsequently failing with a 415 error during
submission. Apply the same fix to the additional validation mentioned at lines
240-246.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@backend/routes/feedback.py`:
- Line 21: The issue is that file.filename is user-controlled and using its
extension directly can produce mismatched file paths or unexpected characters.
Since the MIME type is already validated against the ALLOWED_SCREENSHOT_TYPES
allowlist, create a mapping from MIME types to their corresponding file
extensions (e.g., image/png to .png, image/jpeg to .jpg, etc.). Then, wherever
the file extension is used (in the code around lines 60-73 where file saving
occurs), derive it from the validated MIME type using this mapping instead of
extracting it from the user-provided file.filename.
- Around line 74-83: The direct httpx.put call to SUPABASE_URL with SUPABASE_KEY
in the feedback route violates the approved Supabase access pattern. Move the
file upload logic (the httpx.put call that constructs the Supabase Storage URL
and sends the SUPABASE_KEY) from this route into a dedicated helper function in
backend/db/connection.py, similar to the table() interface. Then replace the
httpx.put code in the feedback route with a call to this new helper function,
ensuring all service-role storage operations are centralized and go through the
approved connection boundary.
- Around line 75-99: The current error handling in the upload screenshot
endpoint only catches HTTP status code failures from the httpx.put call, but
transport-level failures such as timeouts, connection errors, or DNS failures
will raise exceptions before the response object r is created, bypassing the
error mapping and surfacing as 500 errors instead of 502s. Wrap the httpx.put
call in a try-except block to catch httpx transport exceptions (such as
TimeoutException, ConnectError, and similar) and raise the same HTTPException
with status_code 502, following the same pattern as the existing status code
check, to ensure all Supabase storage failures consistently map to 502
responses.
In `@frontend/src/components/ReportIssueFlow.tsx`:
- Around line 63-77: The addFiles function currently validates image files too
broadly by accepting any f.type that starts with "image/", but the backend only
accepts PNG, JPEG, WEBP, and GIF formats. Update the MIME type validation check
(currently checking f.type.startsWith("image/")) to instead validate against a
specific allowlist of only the backend-supported formats: image/png, image/jpeg,
image/webp, and image/gif. This will prevent non-supported image types like SVG,
HEIC, and BMP from passing client validation and subsequently failing with a 415
error during submission. Apply the same fix to the additional validation
mentioned at lines 240-246.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8035ad70-4236-4eb7-afef-d338421399e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6226f and 1807a91.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_issue_screenshot_auth.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 8c71463 into mainJun 17, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the security/231-phase2a-issue-screenshot-backend branch June 17, 2026 01:49
Sign up for freeto 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

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a) - #239

Merged
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend
Jun 17, 2026
Merged

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a)#239
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 15, 2026

Copy link
Copy Markdown
Member

Phase 2a of #231 storage hardening — the app change that must deploy before flipping issues-media-files private (2b).

What

  • BackendPOST /api/issue-reports/screenshot: get_session_user_id (401 unauth) + content-type allowlist (415) + bounded read (413, request_limits.read_within_limit), uploads with the service role, returns the storage path.
  • FrontendReportIssueFlow: uploads via the endpoint (credentials:'include'), stores the path; drops the supabase anon storage client — removing the last live frontend anon-storage path.
  • Testtest_issue_screenshot_auth.py: 401 / 415 / 413 / 200(path scoped to user). All fail pre-fix (endpoint didn't exist).

⚠️ Scope correction worth your eye

While building, I confirmed there is no in-app reader for issue screenshotsfeedback.py only INSERTs, no GET/admin view, nothing renders screenshot_urls. It's write-only collected, exactly like résumés. So I did not build a signed-URL read endpoint: it would have no consumer (dead code). Review is via the Supabase dashboard (works on private buckets).

That changes the gate you set: there are no in-app screenshots to "render," so the pre-2b check isn't "confirm screenshots render" — it's "confirm a new issue-report submission with a screenshot succeeds via the backend in prod." If you'd rather have an in-app admin review view now (which would give the signed-URL read a real consumer), say so and I'll add it; otherwise this matches the résumé decision (dashboard review, deferred admin view).

Sequence

  1. Merge + deploy 2a (backend + frontend) to prod.
  2. Verify a new issue report with a screenshot submits cleanly (backend upload path).
  3. Then apply 2b (flip issues-media-files private + drop its two {public} policies). Existing 2 rows' public URLs stop resolving → dashboard review (same as résumés).

⚠️ Do not merge/deploy until reviewed.

Summary by CodeRabbit

  • New Features
    • Added authenticated screenshot uploads for issue reports, including file-type allowlisting and a 5MB maximum size limit.
    • Updated the issue reporting flow to upload screenshots through the backend and use the returned storage path; in local mode, it also provides a preview.
  • Tests
    • Added regression coverage for authentication enforcement, MIME/type and size validation, successful uploads, and backend upload failures.

…ackend upload (#231 Phase 2a)
Moves issue-report screenshot uploads off the frontend's public anon-key storage
client onto an auth-gated, service-role backend endpoint so issues-media-files
can be made private (Phase 2b).
- Backend: POST /api/issue-reports/screenshot — get_session_user_id (401),
content-type allowlist (415) + bounded read (413) via request_limits, uploads
with the service role, returns the storage PATH.
- Frontend: ReportIssueFlow uploads via the endpoint (credentials:include) and
stores the path; drops the supabase anon storage client (removes the last live
frontend anon-storage path).
- Test: unauth 401, wrong-type 415, oversize 413, valid 200 (path scoped to the
authed user). All fail pre-fix (endpoint didn't exist).
Note: issue screenshots have NO in-app reader (write-only, like résumés), so no
signed-URL read endpoint is included — it would have no consumer; review is via
the dashboard. screenshot_urls now stores paths for new reports.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ef35458-80a2-4924-8f70-9300932d576f

📥 Commits

Reviewing files that changed from the base of the PR and between 1807a91 and 500c025.

📒 Files selected for processing (1)
  • backend/routes/feedback.py
📝 Walkthrough

Walkthrough

Moves issue-report screenshot uploads from direct Supabase client-side uploads to a new authenticated server-side FastAPI endpoint (POST /issue-reports/screenshot). The backend validates MIME type, enforces a 5MB size limit, and uploads using the service role key. The frontend uploadScreenshot helper is updated to call this endpoint instead.

Changes

Screenshot Upload via Backend Endpoint

Layer / File(s)Summary
Backend screenshot upload endpoint
backend/routes/feedback.py
Adds ISSUE_BUCKET, MAX_SCREENSHOT_BYTES, ALLOWED_SCREENSHOT_TYPES constants and the authenticated POST /issue-reports/screenshot handler: validates content type (415), enforces size limit (413), rejects empty bodies (400), constructs a user-scoped storage path, uploads bytes to Supabase via service role credentials, and returns the storage path.
Frontend uploadScreenshot refactor
frontend/src/components/ReportIssueFlow.tsx
Removes direct Supabase client upload logic (supabase import, BUCKET, userId-based path). Replaces with a fetch call to /api/issue-reports/screenshot, returns the backend-provided path, and drops userId from the submit call site. Local-mode blob URL behavior is preserved.
Endpoint regression tests
backend/tests/test_issue_screenshot_auth.py
Adds TestIssueScreenshotUpload with cases covering 401 on unauthenticated access, 415 for wrong MIME type (no outbound upload), 413 for oversized payload (no outbound upload), 200 with user-scoped .png path for a valid upload (exactly one httpx.put call), and 502 for upstream failures while preventing sensitive header/URL leakage.

Sequence Diagram

sequenceDiagram
participant Browser as Browser (ReportIssueFlow)
participant API as FastAPI /issue-reports/screenshot
participant Supabase as Supabase Storage
Browser->>API: POST /api/issue-reports/screenshot (UploadFile + session cookie)
API->>API: decode session → user_id (401 if missing)
API->>API: check content_type vs allowlist (415 on mismatch)
API->>API: read body ≤ 5 MB (413 on oversize, 400 if empty)
API->>Supabase: PUT {user_id}/screenshots/{uuid}.{ext} with service role key
Supabase-->>API: 200 OK
API-->>Browser: {"path": "{user_id}/screenshots/{uuid}.{ext}"}
Browser->>Browser: use path as screenshot_urls in issue report submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A screenshot once flew straight to the cloud,
Now hops through the backend — auth-checked and proud.
The bucket sits safe behind token and gate,
No direct upload shall bypass our state.
Five megabytes max, and the type must be right,
This rabbit secured the uploads tonight! 🖼️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 18.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: auth-gated backend upload for issue-report screenshots as Phase 2a of storage hardening work.
Description check✅ PassedThe description is comprehensive and covers all key sections: what the changes do, specific backend/frontend/test changes, testing status, deployment sequence, and important scope clarifications about screenshot review.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/231-phase2a-issue-screenshot-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend500c025Commit Preview URL

Branch Preview URL
Jun 17 2026, 01:48 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/routes/feedback.py (1)

78-78: 💤 Low value

Dead-code fallback: file.content_type is already validated non-None.

Line 57 rejects requests where (file.content_type or "") is not in ALLOWED_SCREENSHOT_TYPES, so by the time line 78 executes, file.content_type is guaranteed to be a valid string from the allowlist. The or "application/octet-stream" fallback is unreachable.

🧹 Simplify to reflect the invariant
- "Content-Type": file.content_type or "application/octet-stream",+ "Content-Type": file.content_type,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 78, The `or "application/octet-stream"`
fallback in the Content-Type header assignment is unreachable dead code because
the validation at line 57 already ensures that `file.content_type` is a non-None
value from the ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator
and simplify the expression to use `file.content_type` directly, reflecting the
guaranteed invariant at that point in the code.
backend/tests/test_issue_screenshot_auth.py (1)

48-57: 💤 Low value

Test will break if the async httpx fix is applied.

If the endpoint is updated to use httpx.AsyncClient (as suggested for the blocking I/O issue), this mock will need adjustment since patch("routes.feedback.httpx") mocks the synchronous module-level usage.

For an AsyncClient refactor, you'd mock the async context manager and its methods. No action needed now, but keep this in mind when applying the async fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_issue_screenshot_auth.py` around lines 48 - 57, The
current test mocks the synchronous httpx module-level usage. When the endpoint
is refactored to use httpx.AsyncClient, this test will need to be updated to
properly mock the async context manager and its async methods. Specifically,
update the patch target from the module-level httpx to the AsyncClient class,
mock the async context manager's __aenter__ and __aexit__ methods, and ensure
the mocked put method is set up as a coroutine or async mock that can be awaited
within the async context. This adjustment will be necessary once the blocking
I/O issue is addressed and httpx.AsyncClient is introduced in the
routes.feedback module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/feedback.py`:
- Around line 72-81: Replace the synchronous `httpx.put()` call with an async
equivalent to prevent blocking the event loop. Use `httpx.AsyncClient()` with
`await` to make the call non-blocking, and add a timeout parameter to the
request to prevent indefinite hangs if Supabase Storage is unresponsive. You can
either create a new AsyncClient for each request or follow the module-level
client pattern (similar to the sync `_client` pattern mentioned in the comment)
by instantiating an async client at import time and reusing it across requests
for better performance.
---
Nitpick comments:
In `@backend/routes/feedback.py`:
- Line 78: The `or "application/octet-stream"` fallback in the Content-Type
header assignment is unreachable dead code because the validation at line 57
already ensures that `file.content_type` is a non-None value from the
ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator and simplify
the expression to use `file.content_type` directly, reflecting the guaranteed
invariant at that point in the code.
In `@backend/tests/test_issue_screenshot_auth.py`:
- Around line 48-57: The current test mocks the synchronous httpx module-level
usage. When the endpoint is refactored to use httpx.AsyncClient, this test will
need to be updated to properly mock the async context manager and its async
methods. Specifically, update the patch target from the module-level httpx to
the AsyncClient class, mock the async context manager's __aenter__ and __aexit__
methods, and ensure the mocked put method is set up as a coroutine or async mock
that can be awaited within the async context. This adjustment will be necessary
once the blocking I/O issue is addressed and httpx.AsyncClient is introduced in
the routes.feedback module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10aca5a2-e177-41c4-a2f0-6de68c683b63

📥 Commits

Reviewing files that changed from the base of the PR and between ee5b8f1 and 6d6226f.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx

Comment threadbackend/routes/feedback.py Outdated
Comment on lines +72 to +81
r = httpx.put(
url,
content=content,
headers={
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
"Content-Type": file.content_type or "application/octet-stream",
},
)
r.raise_for_status()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronous httpx.put blocks the async event loop.

httpx.put() is a synchronous call inside an async def function. Under concurrent load, each upload blocks the entire event loop until the Supabase round-trip completes, starving other requests.

Additionally, no timeout is specified—if Supabase Storage is slow or unresponsive, the call can hang indefinitely.

🔧 Proposed fix using async httpx with timeout
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- )- r.raise_for_status()+ async with httpx.AsyncClient(timeout=30.0) as http:+ r = await http.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type, # already validated non-None+ },+ )+ r.raise_for_status()

Alternatively, if you prefer to reuse a module-level async client (similar to the sync _client pattern in connection.py), you could instantiate one at import time and reuse it across requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 72 - 81, Replace the synchronous
`httpx.put()` call with an async equivalent to prevent blocking the event loop.
Use `httpx.AsyncClient()` with `await` to make the call non-blocking, and add a
timeout parameter to the request to prevent indefinite hangs if Supabase Storage
is unresponsive. You can either create a new AsyncClient for each request or
follow the module-level client pattern (similar to the sync `_client` pattern
mentioned in the comment) by instantiating an async client at import time and
reusing it across requests for better performance.

…g, timeout
Address PR #239 review:
- ReportIssueFlow.tsx: prefix screenshot upload fetch with API_URL (frontend
and backend are separate origins; bare path hit the frontend host).
- routes/feedback.py: replace r.raise_for_status() with the established
storage_service.upload_avatar pattern — check status, log, and raise
HTTPException(502) with a truncated upstream body (no URL/headers, which
carry the service-role key) instead of a generic 500.
- routes/feedback.py: add timeout=30.0 to the httpx.put (matches db client).
- test_issue_screenshot_auth.py: add Supabase-failure test asserting 502
(not 500) and that the key/URL don't leak; update the success-path mock to
set status_code=200 now that raise_for_status is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/feedback.py (3)

21-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Derive the object extension from the validated MIME type.

file.filename is user-controlled; copying its suffix can produce mismatched paths or URL-significant characters. Since the MIME type is already allowlisted, map it to a known extension.

🐛 Proposed fix
-ALLOWED_SCREENSHOT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}+SCREENSHOT_EXTENSIONS = {+ "image/png": "png",+ "image/jpeg": "jpg",+ "image/webp": "webp",+ "image/gif": "gif",+}+ALLOWED_SCREENSHOT_TYPES = set(SCREENSHOT_EXTENSIONS)
- if (file.content_type or "") not in ALLOWED_SCREENSHOT_TYPES:+ content_type = file.content_type or ""+ if content_type not in ALLOWED_SCREENSHOT_TYPES:
raise HTTPException(
status_code=415,
detail="Unsupported image type. Allowed: PNG, JPEG, WEBP, GIF.",
)
content = await read_within_limit(file, MAX_SCREENSHOT_BYTES) # 413 if oversize
if not content:
raise HTTPException(status_code=400, detail="Empty file.")
- ext = (- file.filename.rsplit(".", 1)[-1]- if file.filename and "." in file.filename- else "png"- )+ ext = SCREENSHOT_EXTENSIONS[content_type]

Also applies to: 60-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 21, The issue is that file.filename is
user-controlled and using its extension directly can produce mismatched file
paths or unexpected characters. Since the MIME type is already validated against
the ALLOWED_SCREENSHOT_TYPES allowlist, create a mapping from MIME types to
their corresponding file extensions (e.g., image/png to .png, image/jpeg to
.jpg, etc.). Then, wherever the file extension is used (in the code around lines
60-73 where file saving occurs), derive it from the validated MIME type using
this mapping instead of extracting it from the user-provided file.filename.

74-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep service-role storage writes behind the Supabase access boundary.

This route composes the Supabase Storage URL and sends SUPABASE_KEY directly from route code. Move the upload into the approved DB/storage boundary, or add an approved helper there, so service-role handling stays centralized.

As per coding guidelines, "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 74 - 83, The direct httpx.put call
to SUPABASE_URL with SUPABASE_KEY in the feedback route violates the approved
Supabase access pattern. Move the file upload logic (the httpx.put call that
constructs the Supabase Storage URL and sends the SUPABASE_KEY) from this route
into a dedicated helper function in backend/db/connection.py, similar to the
table() interface. Then replace the httpx.put code in the feedback route with a
call to this new helper function, ensuring all service-role storage operations
are centralized and go through the approved connection boundary.

Source: Coding guidelines


75-99: ⚠️ Potential issue | 🟠 Major

Map transport failures to 502 too.

timeout=30.0 now bounds the call, but timeout/connect/DNS failures raise before r exists, bypassing the status-code mapping and surfacing as 500s.

🐛 Proposed fix
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- timeout=30.0,- )+ try:+ r = httpx.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type or "application/octet-stream",+ },+ timeout=30.0,+ )+ except httpx.RequestError as exc:+ logger.warning(+ "upload_issue_screenshot: Supabase storage request failed "+ "user=%s error=%s",+ user_id,+ exc.__class__.__name__,+ )+ raise HTTPException(+ status_code=502,+ detail="Screenshot upload failed: storage service unavailable.",+ ) from exc
if r.status_code not in (200, 201):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 75 - 99, The current error handling
in the upload screenshot endpoint only catches HTTP status code failures from
the httpx.put call, but transport-level failures such as timeouts, connection
errors, or DNS failures will raise exceptions before the response object r is
created, bypassing the error mapping and surfacing as 500 errors instead of
502s. Wrap the httpx.put call in a try-except block to catch httpx transport
exceptions (such as TimeoutException, ConnectError, and similar) and raise the
same HTTPException with status_code 502, following the same pattern as the
existing status code check, to ensure all Supabase storage failures consistently
map to 502 responses.
frontend/src/components/ReportIssueFlow.tsx (1)

63-77: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mirror the backend screenshot MIME allowlist in the picker.

The UI accepts any image/*, but the backend only accepts PNG, JPEG, WEBP, and GIF. SVG/HEIC/BMP files pass client validation and then fail during submit with 415.

🐛 Proposed fix
 const MAX_SCREENSHOTS = 5;
const MAX_BYTES = 5 * 1024 * 1024;
+const ALLOWED_SCREENSHOT_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);+const ACCEPTED_SCREENSHOT_TYPES = "image/png,image/jpeg,image/webp,image/gif";
- if (!f.type.startsWith("image/")) {- toast.error(`${f.name} isn't an image`);+ if (!ALLOWED_SCREENSHOT_TYPES.has(f.type)) {+ toast.error(`${f.name} must be PNG, JPEG, WEBP, or GIF`);
return;
}
- accept="image/*"+ accept={ACCEPTED_SCREENSHOT_TYPES}

Also applies to: 240-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ReportIssueFlow.tsx` around lines 63 - 77, The
addFiles function currently validates image files too broadly by accepting any
f.type that starts with "image/", but the backend only accepts PNG, JPEG, WEBP,
and GIF formats. Update the MIME type validation check (currently checking
f.type.startsWith("image/")) to instead validate against a specific allowlist of
only the backend-supported formats: image/png, image/jpeg, image/webp, and
image/gif. This will prevent non-supported image types like SVG, HEIC, and BMP
from passing client validation and subsequently failing with a 415 error during
submission. Apply the same fix to the additional validation mentioned at lines
240-246.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@backend/routes/feedback.py`:
- Line 21: The issue is that file.filename is user-controlled and using its
extension directly can produce mismatched file paths or unexpected characters.
Since the MIME type is already validated against the ALLOWED_SCREENSHOT_TYPES
allowlist, create a mapping from MIME types to their corresponding file
extensions (e.g., image/png to .png, image/jpeg to .jpg, etc.). Then, wherever
the file extension is used (in the code around lines 60-73 where file saving
occurs), derive it from the validated MIME type using this mapping instead of
extracting it from the user-provided file.filename.
- Around line 74-83: The direct httpx.put call to SUPABASE_URL with SUPABASE_KEY
in the feedback route violates the approved Supabase access pattern. Move the
file upload logic (the httpx.put call that constructs the Supabase Storage URL
and sends the SUPABASE_KEY) from this route into a dedicated helper function in
backend/db/connection.py, similar to the table() interface. Then replace the
httpx.put code in the feedback route with a call to this new helper function,
ensuring all service-role storage operations are centralized and go through the
approved connection boundary.
- Around line 75-99: The current error handling in the upload screenshot
endpoint only catches HTTP status code failures from the httpx.put call, but
transport-level failures such as timeouts, connection errors, or DNS failures
will raise exceptions before the response object r is created, bypassing the
error mapping and surfacing as 500 errors instead of 502s. Wrap the httpx.put
call in a try-except block to catch httpx transport exceptions (such as
TimeoutException, ConnectError, and similar) and raise the same HTTPException
with status_code 502, following the same pattern as the existing status code
check, to ensure all Supabase storage failures consistently map to 502
responses.
In `@frontend/src/components/ReportIssueFlow.tsx`:
- Around line 63-77: The addFiles function currently validates image files too
broadly by accepting any f.type that starts with "image/", but the backend only
accepts PNG, JPEG, WEBP, and GIF formats. Update the MIME type validation check
(currently checking f.type.startsWith("image/")) to instead validate against a
specific allowlist of only the backend-supported formats: image/png, image/jpeg,
image/webp, and image/gif. This will prevent non-supported image types like SVG,
HEIC, and BMP from passing client validation and subsequently failing with a 415
error during submission. Apply the same fix to the additional validation
mentioned at lines 240-246.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8035ad70-4236-4eb7-afef-d338421399e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6226f and 1807a91.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_issue_screenshot_auth.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 8c71463 into mainJun 17, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the security/231-phase2a-issue-screenshot-backend branch June 17, 2026 01:49
Sign up for freeto 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

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a) - #239

Merged
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend
Jun 17, 2026
Merged

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a)#239
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 15, 2026

Copy link
Copy Markdown
Member

Phase 2a of #231 storage hardening — the app change that must deploy before flipping issues-media-files private (2b).

What

  • BackendPOST /api/issue-reports/screenshot: get_session_user_id (401 unauth) + content-type allowlist (415) + bounded read (413, request_limits.read_within_limit), uploads with the service role, returns the storage path.
  • FrontendReportIssueFlow: uploads via the endpoint (credentials:'include'), stores the path; drops the supabase anon storage client — removing the last live frontend anon-storage path.
  • Testtest_issue_screenshot_auth.py: 401 / 415 / 413 / 200(path scoped to user). All fail pre-fix (endpoint didn't exist).

⚠️ Scope correction worth your eye

While building, I confirmed there is no in-app reader for issue screenshotsfeedback.py only INSERTs, no GET/admin view, nothing renders screenshot_urls. It's write-only collected, exactly like résumés. So I did not build a signed-URL read endpoint: it would have no consumer (dead code). Review is via the Supabase dashboard (works on private buckets).

That changes the gate you set: there are no in-app screenshots to "render," so the pre-2b check isn't "confirm screenshots render" — it's "confirm a new issue-report submission with a screenshot succeeds via the backend in prod." If you'd rather have an in-app admin review view now (which would give the signed-URL read a real consumer), say so and I'll add it; otherwise this matches the résumé decision (dashboard review, deferred admin view).

Sequence

  1. Merge + deploy 2a (backend + frontend) to prod.
  2. Verify a new issue report with a screenshot submits cleanly (backend upload path).
  3. Then apply 2b (flip issues-media-files private + drop its two {public} policies). Existing 2 rows' public URLs stop resolving → dashboard review (same as résumés).

⚠️ Do not merge/deploy until reviewed.

Summary by CodeRabbit

  • New Features
    • Added authenticated screenshot uploads for issue reports, including file-type allowlisting and a 5MB maximum size limit.
    • Updated the issue reporting flow to upload screenshots through the backend and use the returned storage path; in local mode, it also provides a preview.
  • Tests
    • Added regression coverage for authentication enforcement, MIME/type and size validation, successful uploads, and backend upload failures.

…ackend upload (#231 Phase 2a)
Moves issue-report screenshot uploads off the frontend's public anon-key storage
client onto an auth-gated, service-role backend endpoint so issues-media-files
can be made private (Phase 2b).
- Backend: POST /api/issue-reports/screenshot — get_session_user_id (401),
content-type allowlist (415) + bounded read (413) via request_limits, uploads
with the service role, returns the storage PATH.
- Frontend: ReportIssueFlow uploads via the endpoint (credentials:include) and
stores the path; drops the supabase anon storage client (removes the last live
frontend anon-storage path).
- Test: unauth 401, wrong-type 415, oversize 413, valid 200 (path scoped to the
authed user). All fail pre-fix (endpoint didn't exist).
Note: issue screenshots have NO in-app reader (write-only, like résumés), so no
signed-URL read endpoint is included — it would have no consumer; review is via
the dashboard. screenshot_urls now stores paths for new reports.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ef35458-80a2-4924-8f70-9300932d576f

📥 Commits

Reviewing files that changed from the base of the PR and between 1807a91 and 500c025.

📒 Files selected for processing (1)
  • backend/routes/feedback.py
📝 Walkthrough

Walkthrough

Moves issue-report screenshot uploads from direct Supabase client-side uploads to a new authenticated server-side FastAPI endpoint (POST /issue-reports/screenshot). The backend validates MIME type, enforces a 5MB size limit, and uploads using the service role key. The frontend uploadScreenshot helper is updated to call this endpoint instead.

Changes

Screenshot Upload via Backend Endpoint

Layer / File(s)Summary
Backend screenshot upload endpoint
backend/routes/feedback.py
Adds ISSUE_BUCKET, MAX_SCREENSHOT_BYTES, ALLOWED_SCREENSHOT_TYPES constants and the authenticated POST /issue-reports/screenshot handler: validates content type (415), enforces size limit (413), rejects empty bodies (400), constructs a user-scoped storage path, uploads bytes to Supabase via service role credentials, and returns the storage path.
Frontend uploadScreenshot refactor
frontend/src/components/ReportIssueFlow.tsx
Removes direct Supabase client upload logic (supabase import, BUCKET, userId-based path). Replaces with a fetch call to /api/issue-reports/screenshot, returns the backend-provided path, and drops userId from the submit call site. Local-mode blob URL behavior is preserved.
Endpoint regression tests
backend/tests/test_issue_screenshot_auth.py
Adds TestIssueScreenshotUpload with cases covering 401 on unauthenticated access, 415 for wrong MIME type (no outbound upload), 413 for oversized payload (no outbound upload), 200 with user-scoped .png path for a valid upload (exactly one httpx.put call), and 502 for upstream failures while preventing sensitive header/URL leakage.

Sequence Diagram

sequenceDiagram
participant Browser as Browser (ReportIssueFlow)
participant API as FastAPI /issue-reports/screenshot
participant Supabase as Supabase Storage
Browser->>API: POST /api/issue-reports/screenshot (UploadFile + session cookie)
API->>API: decode session → user_id (401 if missing)
API->>API: check content_type vs allowlist (415 on mismatch)
API->>API: read body ≤ 5 MB (413 on oversize, 400 if empty)
API->>Supabase: PUT {user_id}/screenshots/{uuid}.{ext} with service role key
Supabase-->>API: 200 OK
API-->>Browser: {"path": "{user_id}/screenshots/{uuid}.{ext}"}
Browser->>Browser: use path as screenshot_urls in issue report submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A screenshot once flew straight to the cloud,
Now hops through the backend — auth-checked and proud.
The bucket sits safe behind token and gate,
No direct upload shall bypass our state.
Five megabytes max, and the type must be right,
This rabbit secured the uploads tonight! 🖼️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 18.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: auth-gated backend upload for issue-report screenshots as Phase 2a of storage hardening work.
Description check✅ PassedThe description is comprehensive and covers all key sections: what the changes do, specific backend/frontend/test changes, testing status, deployment sequence, and important scope clarifications about screenshot review.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/231-phase2a-issue-screenshot-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend500c025Commit Preview URL

Branch Preview URL
Jun 17 2026, 01:48 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/routes/feedback.py (1)

78-78: 💤 Low value

Dead-code fallback: file.content_type is already validated non-None.

Line 57 rejects requests where (file.content_type or "") is not in ALLOWED_SCREENSHOT_TYPES, so by the time line 78 executes, file.content_type is guaranteed to be a valid string from the allowlist. The or "application/octet-stream" fallback is unreachable.

🧹 Simplify to reflect the invariant
- "Content-Type": file.content_type or "application/octet-stream",+ "Content-Type": file.content_type,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 78, The `or "application/octet-stream"`
fallback in the Content-Type header assignment is unreachable dead code because
the validation at line 57 already ensures that `file.content_type` is a non-None
value from the ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator
and simplify the expression to use `file.content_type` directly, reflecting the
guaranteed invariant at that point in the code.
backend/tests/test_issue_screenshot_auth.py (1)

48-57: 💤 Low value

Test will break if the async httpx fix is applied.

If the endpoint is updated to use httpx.AsyncClient (as suggested for the blocking I/O issue), this mock will need adjustment since patch("routes.feedback.httpx") mocks the synchronous module-level usage.

For an AsyncClient refactor, you'd mock the async context manager and its methods. No action needed now, but keep this in mind when applying the async fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_issue_screenshot_auth.py` around lines 48 - 57, The
current test mocks the synchronous httpx module-level usage. When the endpoint
is refactored to use httpx.AsyncClient, this test will need to be updated to
properly mock the async context manager and its async methods. Specifically,
update the patch target from the module-level httpx to the AsyncClient class,
mock the async context manager's __aenter__ and __aexit__ methods, and ensure
the mocked put method is set up as a coroutine or async mock that can be awaited
within the async context. This adjustment will be necessary once the blocking
I/O issue is addressed and httpx.AsyncClient is introduced in the
routes.feedback module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/feedback.py`:
- Around line 72-81: Replace the synchronous `httpx.put()` call with an async
equivalent to prevent blocking the event loop. Use `httpx.AsyncClient()` with
`await` to make the call non-blocking, and add a timeout parameter to the
request to prevent indefinite hangs if Supabase Storage is unresponsive. You can
either create a new AsyncClient for each request or follow the module-level
client pattern (similar to the sync `_client` pattern mentioned in the comment)
by instantiating an async client at import time and reusing it across requests
for better performance.
---
Nitpick comments:
In `@backend/routes/feedback.py`:
- Line 78: The `or "application/octet-stream"` fallback in the Content-Type
header assignment is unreachable dead code because the validation at line 57
already ensures that `file.content_type` is a non-None value from the
ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator and simplify
the expression to use `file.content_type` directly, reflecting the guaranteed
invariant at that point in the code.
In `@backend/tests/test_issue_screenshot_auth.py`:
- Around line 48-57: The current test mocks the synchronous httpx module-level
usage. When the endpoint is refactored to use httpx.AsyncClient, this test will
need to be updated to properly mock the async context manager and its async
methods. Specifically, update the patch target from the module-level httpx to
the AsyncClient class, mock the async context manager's __aenter__ and __aexit__
methods, and ensure the mocked put method is set up as a coroutine or async mock
that can be awaited within the async context. This adjustment will be necessary
once the blocking I/O issue is addressed and httpx.AsyncClient is introduced in
the routes.feedback module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10aca5a2-e177-41c4-a2f0-6de68c683b63

📥 Commits

Reviewing files that changed from the base of the PR and between ee5b8f1 and 6d6226f.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx

Comment threadbackend/routes/feedback.py Outdated
Comment on lines +72 to +81
r = httpx.put(
url,
content=content,
headers={
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
"Content-Type": file.content_type or "application/octet-stream",
},
)
r.raise_for_status()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronous httpx.put blocks the async event loop.

httpx.put() is a synchronous call inside an async def function. Under concurrent load, each upload blocks the entire event loop until the Supabase round-trip completes, starving other requests.

Additionally, no timeout is specified—if Supabase Storage is slow or unresponsive, the call can hang indefinitely.

🔧 Proposed fix using async httpx with timeout
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- )- r.raise_for_status()+ async with httpx.AsyncClient(timeout=30.0) as http:+ r = await http.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type, # already validated non-None+ },+ )+ r.raise_for_status()

Alternatively, if you prefer to reuse a module-level async client (similar to the sync _client pattern in connection.py), you could instantiate one at import time and reuse it across requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 72 - 81, Replace the synchronous
`httpx.put()` call with an async equivalent to prevent blocking the event loop.
Use `httpx.AsyncClient()` with `await` to make the call non-blocking, and add a
timeout parameter to the request to prevent indefinite hangs if Supabase Storage
is unresponsive. You can either create a new AsyncClient for each request or
follow the module-level client pattern (similar to the sync `_client` pattern
mentioned in the comment) by instantiating an async client at import time and
reusing it across requests for better performance.

…g, timeout
Address PR #239 review:
- ReportIssueFlow.tsx: prefix screenshot upload fetch with API_URL (frontend
and backend are separate origins; bare path hit the frontend host).
- routes/feedback.py: replace r.raise_for_status() with the established
storage_service.upload_avatar pattern — check status, log, and raise
HTTPException(502) with a truncated upstream body (no URL/headers, which
carry the service-role key) instead of a generic 500.
- routes/feedback.py: add timeout=30.0 to the httpx.put (matches db client).
- test_issue_screenshot_auth.py: add Supabase-failure test asserting 502
(not 500) and that the key/URL don't leak; update the success-path mock to
set status_code=200 now that raise_for_status is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/feedback.py (3)

21-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Derive the object extension from the validated MIME type.

file.filename is user-controlled; copying its suffix can produce mismatched paths or URL-significant characters. Since the MIME type is already allowlisted, map it to a known extension.

🐛 Proposed fix
-ALLOWED_SCREENSHOT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}+SCREENSHOT_EXTENSIONS = {+ "image/png": "png",+ "image/jpeg": "jpg",+ "image/webp": "webp",+ "image/gif": "gif",+}+ALLOWED_SCREENSHOT_TYPES = set(SCREENSHOT_EXTENSIONS)
- if (file.content_type or "") not in ALLOWED_SCREENSHOT_TYPES:+ content_type = file.content_type or ""+ if content_type not in ALLOWED_SCREENSHOT_TYPES:
raise HTTPException(
status_code=415,
detail="Unsupported image type. Allowed: PNG, JPEG, WEBP, GIF.",
)
content = await read_within_limit(file, MAX_SCREENSHOT_BYTES) # 413 if oversize
if not content:
raise HTTPException(status_code=400, detail="Empty file.")
- ext = (- file.filename.rsplit(".", 1)[-1]- if file.filename and "." in file.filename- else "png"- )+ ext = SCREENSHOT_EXTENSIONS[content_type]

Also applies to: 60-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 21, The issue is that file.filename is
user-controlled and using its extension directly can produce mismatched file
paths or unexpected characters. Since the MIME type is already validated against
the ALLOWED_SCREENSHOT_TYPES allowlist, create a mapping from MIME types to
their corresponding file extensions (e.g., image/png to .png, image/jpeg to
.jpg, etc.). Then, wherever the file extension is used (in the code around lines
60-73 where file saving occurs), derive it from the validated MIME type using
this mapping instead of extracting it from the user-provided file.filename.

74-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep service-role storage writes behind the Supabase access boundary.

This route composes the Supabase Storage URL and sends SUPABASE_KEY directly from route code. Move the upload into the approved DB/storage boundary, or add an approved helper there, so service-role handling stays centralized.

As per coding guidelines, "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 74 - 83, The direct httpx.put call
to SUPABASE_URL with SUPABASE_KEY in the feedback route violates the approved
Supabase access pattern. Move the file upload logic (the httpx.put call that
constructs the Supabase Storage URL and sends the SUPABASE_KEY) from this route
into a dedicated helper function in backend/db/connection.py, similar to the
table() interface. Then replace the httpx.put code in the feedback route with a
call to this new helper function, ensuring all service-role storage operations
are centralized and go through the approved connection boundary.

Source: Coding guidelines


75-99: ⚠️ Potential issue | 🟠 Major

Map transport failures to 502 too.

timeout=30.0 now bounds the call, but timeout/connect/DNS failures raise before r exists, bypassing the status-code mapping and surfacing as 500s.

🐛 Proposed fix
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- timeout=30.0,- )+ try:+ r = httpx.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type or "application/octet-stream",+ },+ timeout=30.0,+ )+ except httpx.RequestError as exc:+ logger.warning(+ "upload_issue_screenshot: Supabase storage request failed "+ "user=%s error=%s",+ user_id,+ exc.__class__.__name__,+ )+ raise HTTPException(+ status_code=502,+ detail="Screenshot upload failed: storage service unavailable.",+ ) from exc
if r.status_code not in (200, 201):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 75 - 99, The current error handling
in the upload screenshot endpoint only catches HTTP status code failures from
the httpx.put call, but transport-level failures such as timeouts, connection
errors, or DNS failures will raise exceptions before the response object r is
created, bypassing the error mapping and surfacing as 500 errors instead of
502s. Wrap the httpx.put call in a try-except block to catch httpx transport
exceptions (such as TimeoutException, ConnectError, and similar) and raise the
same HTTPException with status_code 502, following the same pattern as the
existing status code check, to ensure all Supabase storage failures consistently
map to 502 responses.
frontend/src/components/ReportIssueFlow.tsx (1)

63-77: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mirror the backend screenshot MIME allowlist in the picker.

The UI accepts any image/*, but the backend only accepts PNG, JPEG, WEBP, and GIF. SVG/HEIC/BMP files pass client validation and then fail during submit with 415.

🐛 Proposed fix
 const MAX_SCREENSHOTS = 5;
const MAX_BYTES = 5 * 1024 * 1024;
+const ALLOWED_SCREENSHOT_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);+const ACCEPTED_SCREENSHOT_TYPES = "image/png,image/jpeg,image/webp,image/gif";
- if (!f.type.startsWith("image/")) {- toast.error(`${f.name} isn't an image`);+ if (!ALLOWED_SCREENSHOT_TYPES.has(f.type)) {+ toast.error(`${f.name} must be PNG, JPEG, WEBP, or GIF`);
return;
}
- accept="image/*"+ accept={ACCEPTED_SCREENSHOT_TYPES}

Also applies to: 240-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ReportIssueFlow.tsx` around lines 63 - 77, The
addFiles function currently validates image files too broadly by accepting any
f.type that starts with "image/", but the backend only accepts PNG, JPEG, WEBP,
and GIF formats. Update the MIME type validation check (currently checking
f.type.startsWith("image/")) to instead validate against a specific allowlist of
only the backend-supported formats: image/png, image/jpeg, image/webp, and
image/gif. This will prevent non-supported image types like SVG, HEIC, and BMP
from passing client validation and subsequently failing with a 415 error during
submission. Apply the same fix to the additional validation mentioned at lines
240-246.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@backend/routes/feedback.py`:
- Line 21: The issue is that file.filename is user-controlled and using its
extension directly can produce mismatched file paths or unexpected characters.
Since the MIME type is already validated against the ALLOWED_SCREENSHOT_TYPES
allowlist, create a mapping from MIME types to their corresponding file
extensions (e.g., image/png to .png, image/jpeg to .jpg, etc.). Then, wherever
the file extension is used (in the code around lines 60-73 where file saving
occurs), derive it from the validated MIME type using this mapping instead of
extracting it from the user-provided file.filename.
- Around line 74-83: The direct httpx.put call to SUPABASE_URL with SUPABASE_KEY
in the feedback route violates the approved Supabase access pattern. Move the
file upload logic (the httpx.put call that constructs the Supabase Storage URL
and sends the SUPABASE_KEY) from this route into a dedicated helper function in
backend/db/connection.py, similar to the table() interface. Then replace the
httpx.put code in the feedback route with a call to this new helper function,
ensuring all service-role storage operations are centralized and go through the
approved connection boundary.
- Around line 75-99: The current error handling in the upload screenshot
endpoint only catches HTTP status code failures from the httpx.put call, but
transport-level failures such as timeouts, connection errors, or DNS failures
will raise exceptions before the response object r is created, bypassing the
error mapping and surfacing as 500 errors instead of 502s. Wrap the httpx.put
call in a try-except block to catch httpx transport exceptions (such as
TimeoutException, ConnectError, and similar) and raise the same HTTPException
with status_code 502, following the same pattern as the existing status code
check, to ensure all Supabase storage failures consistently map to 502
responses.
In `@frontend/src/components/ReportIssueFlow.tsx`:
- Around line 63-77: The addFiles function currently validates image files too
broadly by accepting any f.type that starts with "image/", but the backend only
accepts PNG, JPEG, WEBP, and GIF formats. Update the MIME type validation check
(currently checking f.type.startsWith("image/")) to instead validate against a
specific allowlist of only the backend-supported formats: image/png, image/jpeg,
image/webp, and image/gif. This will prevent non-supported image types like SVG,
HEIC, and BMP from passing client validation and subsequently failing with a 415
error during submission. Apply the same fix to the additional validation
mentioned at lines 240-246.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8035ad70-4236-4eb7-afef-d338421399e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6226f and 1807a91.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_issue_screenshot_auth.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 8c71463 into mainJun 17, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the security/231-phase2a-issue-screenshot-backend branch June 17, 2026 01:49
Sign up for freeto 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

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a) - #239

Merged
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend
Jun 17, 2026
Merged

fix(security): issue-report screenshots via auth-gated backend upload (#231 Phase 2a)#239
Jose-Gael-Cruz-Lopez merged 3 commits into
mainfrom
security/231-phase2a-issue-screenshot-backend

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 15, 2026

Copy link
Copy Markdown
Member

Phase 2a of #231 storage hardening — the app change that must deploy before flipping issues-media-files private (2b).

What

  • BackendPOST /api/issue-reports/screenshot: get_session_user_id (401 unauth) + content-type allowlist (415) + bounded read (413, request_limits.read_within_limit), uploads with the service role, returns the storage path.
  • FrontendReportIssueFlow: uploads via the endpoint (credentials:'include'), stores the path; drops the supabase anon storage client — removing the last live frontend anon-storage path.
  • Testtest_issue_screenshot_auth.py: 401 / 415 / 413 / 200(path scoped to user). All fail pre-fix (endpoint didn't exist).

⚠️ Scope correction worth your eye

While building, I confirmed there is no in-app reader for issue screenshotsfeedback.py only INSERTs, no GET/admin view, nothing renders screenshot_urls. It's write-only collected, exactly like résumés. So I did not build a signed-URL read endpoint: it would have no consumer (dead code). Review is via the Supabase dashboard (works on private buckets).

That changes the gate you set: there are no in-app screenshots to "render," so the pre-2b check isn't "confirm screenshots render" — it's "confirm a new issue-report submission with a screenshot succeeds via the backend in prod." If you'd rather have an in-app admin review view now (which would give the signed-URL read a real consumer), say so and I'll add it; otherwise this matches the résumé decision (dashboard review, deferred admin view).

Sequence

  1. Merge + deploy 2a (backend + frontend) to prod.
  2. Verify a new issue report with a screenshot submits cleanly (backend upload path).
  3. Then apply 2b (flip issues-media-files private + drop its two {public} policies). Existing 2 rows' public URLs stop resolving → dashboard review (same as résumés).

⚠️ Do not merge/deploy until reviewed.

Summary by CodeRabbit

  • New Features
    • Added authenticated screenshot uploads for issue reports, including file-type allowlisting and a 5MB maximum size limit.
    • Updated the issue reporting flow to upload screenshots through the backend and use the returned storage path; in local mode, it also provides a preview.
  • Tests
    • Added regression coverage for authentication enforcement, MIME/type and size validation, successful uploads, and backend upload failures.

…ackend upload (#231 Phase 2a)
Moves issue-report screenshot uploads off the frontend's public anon-key storage
client onto an auth-gated, service-role backend endpoint so issues-media-files
can be made private (Phase 2b).
- Backend: POST /api/issue-reports/screenshot — get_session_user_id (401),
content-type allowlist (415) + bounded read (413) via request_limits, uploads
with the service role, returns the storage PATH.
- Frontend: ReportIssueFlow uploads via the endpoint (credentials:include) and
stores the path; drops the supabase anon storage client (removes the last live
frontend anon-storage path).
- Test: unauth 401, wrong-type 415, oversize 413, valid 200 (path scoped to the
authed user). All fail pre-fix (endpoint didn't exist).
Note: issue screenshots have NO in-app reader (write-only, like résumés), so no
signed-URL read endpoint is included — it would have no consumer; review is via
the dashboard. screenshot_urls now stores paths for new reports.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ef35458-80a2-4924-8f70-9300932d576f

📥 Commits

Reviewing files that changed from the base of the PR and between 1807a91 and 500c025.

📒 Files selected for processing (1)
  • backend/routes/feedback.py
📝 Walkthrough

Walkthrough

Moves issue-report screenshot uploads from direct Supabase client-side uploads to a new authenticated server-side FastAPI endpoint (POST /issue-reports/screenshot). The backend validates MIME type, enforces a 5MB size limit, and uploads using the service role key. The frontend uploadScreenshot helper is updated to call this endpoint instead.

Changes

Screenshot Upload via Backend Endpoint

Layer / File(s)Summary
Backend screenshot upload endpoint
backend/routes/feedback.py
Adds ISSUE_BUCKET, MAX_SCREENSHOT_BYTES, ALLOWED_SCREENSHOT_TYPES constants and the authenticated POST /issue-reports/screenshot handler: validates content type (415), enforces size limit (413), rejects empty bodies (400), constructs a user-scoped storage path, uploads bytes to Supabase via service role credentials, and returns the storage path.
Frontend uploadScreenshot refactor
frontend/src/components/ReportIssueFlow.tsx
Removes direct Supabase client upload logic (supabase import, BUCKET, userId-based path). Replaces with a fetch call to /api/issue-reports/screenshot, returns the backend-provided path, and drops userId from the submit call site. Local-mode blob URL behavior is preserved.
Endpoint regression tests
backend/tests/test_issue_screenshot_auth.py
Adds TestIssueScreenshotUpload with cases covering 401 on unauthenticated access, 415 for wrong MIME type (no outbound upload), 413 for oversized payload (no outbound upload), 200 with user-scoped .png path for a valid upload (exactly one httpx.put call), and 502 for upstream failures while preventing sensitive header/URL leakage.

Sequence Diagram

sequenceDiagram
participant Browser as Browser (ReportIssueFlow)
participant API as FastAPI /issue-reports/screenshot
participant Supabase as Supabase Storage
Browser->>API: POST /api/issue-reports/screenshot (UploadFile + session cookie)
API->>API: decode session → user_id (401 if missing)
API->>API: check content_type vs allowlist (415 on mismatch)
API->>API: read body ≤ 5 MB (413 on oversize, 400 if empty)
API->>Supabase: PUT {user_id}/screenshots/{uuid}.{ext} with service role key
Supabase-->>API: 200 OK
API-->>Browser: {"path": "{user_id}/screenshots/{uuid}.{ext}"}
Browser->>Browser: use path as screenshot_urls in issue report submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A screenshot once flew straight to the cloud,
Now hops through the backend — auth-checked and proud.
The bucket sits safe behind token and gate,
No direct upload shall bypass our state.
Five megabytes max, and the type must be right,
This rabbit secured the uploads tonight! 🖼️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 18.18% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: auth-gated backend upload for issue-report screenshots as Phase 2a of storage hardening work.
Description check✅ PassedThe description is comprehensive and covers all key sections: what the changes do, specific backend/frontend/test changes, testing status, deployment sequence, and important scope clarifications about screenshot review.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/231-phase2a-issue-screenshot-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend500c025Commit Preview URL

Branch Preview URL
Jun 17 2026, 01:48 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
backend/routes/feedback.py (1)

78-78: 💤 Low value

Dead-code fallback: file.content_type is already validated non-None.

Line 57 rejects requests where (file.content_type or "") is not in ALLOWED_SCREENSHOT_TYPES, so by the time line 78 executes, file.content_type is guaranteed to be a valid string from the allowlist. The or "application/octet-stream" fallback is unreachable.

🧹 Simplify to reflect the invariant
- "Content-Type": file.content_type or "application/octet-stream",+ "Content-Type": file.content_type,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 78, The `or "application/octet-stream"`
fallback in the Content-Type header assignment is unreachable dead code because
the validation at line 57 already ensures that `file.content_type` is a non-None
value from the ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator
and simplify the expression to use `file.content_type` directly, reflecting the
guaranteed invariant at that point in the code.
backend/tests/test_issue_screenshot_auth.py (1)

48-57: 💤 Low value

Test will break if the async httpx fix is applied.

If the endpoint is updated to use httpx.AsyncClient (as suggested for the blocking I/O issue), this mock will need adjustment since patch("routes.feedback.httpx") mocks the synchronous module-level usage.

For an AsyncClient refactor, you'd mock the async context manager and its methods. No action needed now, but keep this in mind when applying the async fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_issue_screenshot_auth.py` around lines 48 - 57, The
current test mocks the synchronous httpx module-level usage. When the endpoint
is refactored to use httpx.AsyncClient, this test will need to be updated to
properly mock the async context manager and its async methods. Specifically,
update the patch target from the module-level httpx to the AsyncClient class,
mock the async context manager's __aenter__ and __aexit__ methods, and ensure
the mocked put method is set up as a coroutine or async mock that can be awaited
within the async context. This adjustment will be necessary once the blocking
I/O issue is addressed and httpx.AsyncClient is introduced in the
routes.feedback module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/feedback.py`:
- Around line 72-81: Replace the synchronous `httpx.put()` call with an async
equivalent to prevent blocking the event loop. Use `httpx.AsyncClient()` with
`await` to make the call non-blocking, and add a timeout parameter to the
request to prevent indefinite hangs if Supabase Storage is unresponsive. You can
either create a new AsyncClient for each request or follow the module-level
client pattern (similar to the sync `_client` pattern mentioned in the comment)
by instantiating an async client at import time and reusing it across requests
for better performance.
---
Nitpick comments:
In `@backend/routes/feedback.py`:
- Line 78: The `or "application/octet-stream"` fallback in the Content-Type
header assignment is unreachable dead code because the validation at line 57
already ensures that `file.content_type` is a non-None value from the
ALLOWED_SCREENSHOT_TYPES allowlist. Remove the fallback operator and simplify
the expression to use `file.content_type` directly, reflecting the guaranteed
invariant at that point in the code.
In `@backend/tests/test_issue_screenshot_auth.py`:
- Around line 48-57: The current test mocks the synchronous httpx module-level
usage. When the endpoint is refactored to use httpx.AsyncClient, this test will
need to be updated to properly mock the async context manager and its async
methods. Specifically, update the patch target from the module-level httpx to
the AsyncClient class, mock the async context manager's __aenter__ and __aexit__
methods, and ensure the mocked put method is set up as a coroutine or async mock
that can be awaited within the async context. This adjustment will be necessary
once the blocking I/O issue is addressed and httpx.AsyncClient is introduced in
the routes.feedback module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10aca5a2-e177-41c4-a2f0-6de68c683b63

📥 Commits

Reviewing files that changed from the base of the PR and between ee5b8f1 and 6d6226f.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx

Comment threadbackend/routes/feedback.py Outdated
Comment on lines +72 to +81
r = httpx.put(
url,
content=content,
headers={
"apikey": SUPABASE_KEY,
"Authorization": f"Bearer {SUPABASE_KEY}",
"Content-Type": file.content_type or "application/octet-stream",
},
)
r.raise_for_status()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronous httpx.put blocks the async event loop.

httpx.put() is a synchronous call inside an async def function. Under concurrent load, each upload blocks the entire event loop until the Supabase round-trip completes, starving other requests.

Additionally, no timeout is specified—if Supabase Storage is slow or unresponsive, the call can hang indefinitely.

🔧 Proposed fix using async httpx with timeout
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- )- r.raise_for_status()+ async with httpx.AsyncClient(timeout=30.0) as http:+ r = await http.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type, # already validated non-None+ },+ )+ r.raise_for_status()

Alternatively, if you prefer to reuse a module-level async client (similar to the sync _client pattern in connection.py), you could instantiate one at import time and reuse it across requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 72 - 81, Replace the synchronous
`httpx.put()` call with an async equivalent to prevent blocking the event loop.
Use `httpx.AsyncClient()` with `await` to make the call non-blocking, and add a
timeout parameter to the request to prevent indefinite hangs if Supabase Storage
is unresponsive. You can either create a new AsyncClient for each request or
follow the module-level client pattern (similar to the sync `_client` pattern
mentioned in the comment) by instantiating an async client at import time and
reusing it across requests for better performance.

…g, timeout
Address PR #239 review:
- ReportIssueFlow.tsx: prefix screenshot upload fetch with API_URL (frontend
and backend are separate origins; bare path hit the frontend host).
- routes/feedback.py: replace r.raise_for_status() with the established
storage_service.upload_avatar pattern — check status, log, and raise
HTTPException(502) with a truncated upstream body (no URL/headers, which
carry the service-role key) instead of a generic 500.
- routes/feedback.py: add timeout=30.0 to the httpx.put (matches db client).
- test_issue_screenshot_auth.py: add Supabase-failure test asserting 502
(not 500) and that the key/URL don't leak; update the success-path mock to
set status_code=200 now that raise_for_status is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/feedback.py (3)

21-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Derive the object extension from the validated MIME type.

file.filename is user-controlled; copying its suffix can produce mismatched paths or URL-significant characters. Since the MIME type is already allowlisted, map it to a known extension.

🐛 Proposed fix
-ALLOWED_SCREENSHOT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}+SCREENSHOT_EXTENSIONS = {+ "image/png": "png",+ "image/jpeg": "jpg",+ "image/webp": "webp",+ "image/gif": "gif",+}+ALLOWED_SCREENSHOT_TYPES = set(SCREENSHOT_EXTENSIONS)
- if (file.content_type or "") not in ALLOWED_SCREENSHOT_TYPES:+ content_type = file.content_type or ""+ if content_type not in ALLOWED_SCREENSHOT_TYPES:
raise HTTPException(
status_code=415,
detail="Unsupported image type. Allowed: PNG, JPEG, WEBP, GIF.",
)
content = await read_within_limit(file, MAX_SCREENSHOT_BYTES) # 413 if oversize
if not content:
raise HTTPException(status_code=400, detail="Empty file.")
- ext = (- file.filename.rsplit(".", 1)[-1]- if file.filename and "." in file.filename- else "png"- )+ ext = SCREENSHOT_EXTENSIONS[content_type]

Also applies to: 60-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` at line 21, The issue is that file.filename is
user-controlled and using its extension directly can produce mismatched file
paths or unexpected characters. Since the MIME type is already validated against
the ALLOWED_SCREENSHOT_TYPES allowlist, create a mapping from MIME types to
their corresponding file extensions (e.g., image/png to .png, image/jpeg to
.jpg, etc.). Then, wherever the file extension is used (in the code around lines
60-73 where file saving occurs), derive it from the validated MIME type using
this mapping instead of extracting it from the user-provided file.filename.

74-83: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep service-role storage writes behind the Supabase access boundary.

This route composes the Supabase Storage URL and sends SUPABASE_KEY directly from route code. Move the upload into the approved DB/storage boundary, or add an approved helper there, so service-role handling stays centralized.

As per coding guidelines, "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 74 - 83, The direct httpx.put call
to SUPABASE_URL with SUPABASE_KEY in the feedback route violates the approved
Supabase access pattern. Move the file upload logic (the httpx.put call that
constructs the Supabase Storage URL and sends the SUPABASE_KEY) from this route
into a dedicated helper function in backend/db/connection.py, similar to the
table() interface. Then replace the httpx.put code in the feedback route with a
call to this new helper function, ensuring all service-role storage operations
are centralized and go through the approved connection boundary.

Source: Coding guidelines


75-99: ⚠️ Potential issue | 🟠 Major

Map transport failures to 502 too.

timeout=30.0 now bounds the call, but timeout/connect/DNS failures raise before r exists, bypassing the status-code mapping and surfacing as 500s.

🐛 Proposed fix
- r = httpx.put(- url,- content=content,- headers={- "apikey": SUPABASE_KEY,- "Authorization": f"Bearer {SUPABASE_KEY}",- "Content-Type": file.content_type or "application/octet-stream",- },- timeout=30.0,- )+ try:+ r = httpx.put(+ url,+ content=content,+ headers={+ "apikey": SUPABASE_KEY,+ "Authorization": f"Bearer {SUPABASE_KEY}",+ "Content-Type": file.content_type or "application/octet-stream",+ },+ timeout=30.0,+ )+ except httpx.RequestError as exc:+ logger.warning(+ "upload_issue_screenshot: Supabase storage request failed "+ "user=%s error=%s",+ user_id,+ exc.__class__.__name__,+ )+ raise HTTPException(+ status_code=502,+ detail="Screenshot upload failed: storage service unavailable.",+ ) from exc
if r.status_code not in (200, 201):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/feedback.py` around lines 75 - 99, The current error handling
in the upload screenshot endpoint only catches HTTP status code failures from
the httpx.put call, but transport-level failures such as timeouts, connection
errors, or DNS failures will raise exceptions before the response object r is
created, bypassing the error mapping and surfacing as 500 errors instead of
502s. Wrap the httpx.put call in a try-except block to catch httpx transport
exceptions (such as TimeoutException, ConnectError, and similar) and raise the
same HTTPException with status_code 502, following the same pattern as the
existing status code check, to ensure all Supabase storage failures consistently
map to 502 responses.
frontend/src/components/ReportIssueFlow.tsx (1)

63-77: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mirror the backend screenshot MIME allowlist in the picker.

The UI accepts any image/*, but the backend only accepts PNG, JPEG, WEBP, and GIF. SVG/HEIC/BMP files pass client validation and then fail during submit with 415.

🐛 Proposed fix
 const MAX_SCREENSHOTS = 5;
const MAX_BYTES = 5 * 1024 * 1024;
+const ALLOWED_SCREENSHOT_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);+const ACCEPTED_SCREENSHOT_TYPES = "image/png,image/jpeg,image/webp,image/gif";
- if (!f.type.startsWith("image/")) {- toast.error(`${f.name} isn't an image`);+ if (!ALLOWED_SCREENSHOT_TYPES.has(f.type)) {+ toast.error(`${f.name} must be PNG, JPEG, WEBP, or GIF`);
return;
}
- accept="image/*"+ accept={ACCEPTED_SCREENSHOT_TYPES}

Also applies to: 240-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ReportIssueFlow.tsx` around lines 63 - 77, The
addFiles function currently validates image files too broadly by accepting any
f.type that starts with "image/", but the backend only accepts PNG, JPEG, WEBP,
and GIF formats. Update the MIME type validation check (currently checking
f.type.startsWith("image/")) to instead validate against a specific allowlist of
only the backend-supported formats: image/png, image/jpeg, image/webp, and
image/gif. This will prevent non-supported image types like SVG, HEIC, and BMP
from passing client validation and subsequently failing with a 415 error during
submission. Apply the same fix to the additional validation mentioned at lines
240-246.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@backend/routes/feedback.py`:
- Line 21: The issue is that file.filename is user-controlled and using its
extension directly can produce mismatched file paths or unexpected characters.
Since the MIME type is already validated against the ALLOWED_SCREENSHOT_TYPES
allowlist, create a mapping from MIME types to their corresponding file
extensions (e.g., image/png to .png, image/jpeg to .jpg, etc.). Then, wherever
the file extension is used (in the code around lines 60-73 where file saving
occurs), derive it from the validated MIME type using this mapping instead of
extracting it from the user-provided file.filename.
- Around line 74-83: The direct httpx.put call to SUPABASE_URL with SUPABASE_KEY
in the feedback route violates the approved Supabase access pattern. Move the
file upload logic (the httpx.put call that constructs the Supabase Storage URL
and sends the SUPABASE_KEY) from this route into a dedicated helper function in
backend/db/connection.py, similar to the table() interface. Then replace the
httpx.put code in the feedback route with a call to this new helper function,
ensuring all service-role storage operations are centralized and go through the
approved connection boundary.
- Around line 75-99: The current error handling in the upload screenshot
endpoint only catches HTTP status code failures from the httpx.put call, but
transport-level failures such as timeouts, connection errors, or DNS failures
will raise exceptions before the response object r is created, bypassing the
error mapping and surfacing as 500 errors instead of 502s. Wrap the httpx.put
call in a try-except block to catch httpx transport exceptions (such as
TimeoutException, ConnectError, and similar) and raise the same HTTPException
with status_code 502, following the same pattern as the existing status code
check, to ensure all Supabase storage failures consistently map to 502
responses.
In `@frontend/src/components/ReportIssueFlow.tsx`:
- Around line 63-77: The addFiles function currently validates image files too
broadly by accepting any f.type that starts with "image/", but the backend only
accepts PNG, JPEG, WEBP, and GIF formats. Update the MIME type validation check
(currently checking f.type.startsWith("image/")) to instead validate against a
specific allowlist of only the backend-supported formats: image/png, image/jpeg,
image/webp, and image/gif. This will prevent non-supported image types like SVG,
HEIC, and BMP from passing client validation and subsequently failing with a 415
error during submission. Apply the same fix to the additional validation
mentioned at lines 240-246.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8035ad70-4236-4eb7-afef-d338421399e4

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6226f and 1807a91.

📒 Files selected for processing (3)
  • backend/routes/feedback.py
  • backend/tests/test_issue_screenshot_auth.py
  • frontend/src/components/ReportIssueFlow.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_issue_screenshot_auth.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 8c71463 into mainJun 17, 2026
6 checks passed
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the security/231-phase2a-issue-screenshot-backend branch June 17, 2026 01:49
Sign up for freeto 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

@Jose-Gael-Cruz-Lopez