feat(api): restrict avatarUrl to GCS public-image bucket in shared router#260
feat(api): restrict avatarUrl to GCS public-image bucket in shared router#260pstaylor-patrick wants to merge 3 commits into
Conversation
03a4d8a to
cb7cd81
Compare
|
Team is interested in doing this. But would require every image to be migrated into GCP. Option 2 for allow list could ease us in. |
30c68c9 to
5d9089a
Compare
2d5816a to
bb6e3d9
Compare
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe ChangesAvatar URL Restriction
🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/api/src/router/me/me.test.ts (1)
307-336: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winAdd a positive
f3-public-images-stagingtest.The contract now allows both prod and staging buckets, but this coverage only proves prod passes and non-allowed URLs fail. A regression in the optional
-stagingbranch would slip through unnoticed.🤖 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 `@packages/api/src/router/me/me.test.ts` around lines 307 - 336, Add a positive test case mirroring "should update avatarUrl with a valid GCS public-image URL" but using the staging bucket hostname/path: construct a URL like `https://storage.googleapis.com/f3-public-images-staging/user-avatars/${testUserId}.jpg`, call createDirectClient() and client.me.updateProfile({ avatarUrl: url }), assert result.user.avatarUrl equals the staging URL, and perform the same cleanup by resetting avatarUrl to null; place this alongside the existing tests so both prod and staging buckets are covered.
🤖 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 `@packages/api/src/router/me/index.ts`:
- Around line 63-72: The avatarUrl zod refine currently rejects non-GCS URLs via
isAllowedAvatarUrl which will break PATCH for users with legacy avatar hosts;
update the refine to only enforce isAllowedAvatarUrl when the account has passed
the legacy-avatar migration (or when a global migration flag is enabled),
otherwise allow legacy URLs that are on a temporary allow-list; implement a
small helper (e.g., isLegacyAvatarAllowed or use an environment flag
LEGACY_AVATAR_ALLOWLIST/ENABLE_LEGACY_AVATARS) and change the refine on
avatarUrl to: accept null/empty, accept GCS via isAllowedAvatarUrl, or accept
legacy-host URLs when isLegacyAvatarAllowed(session.user.id or the URL) returns
true so existing profiles aren’t rejected until migrated.
---
Outside diff comments:
In `@packages/api/src/router/me/me.test.ts`:
- Around line 307-336: Add a positive test case mirroring "should update
avatarUrl with a valid GCS public-image URL" but using the staging bucket
hostname/path: construct a URL like
`https://storage.googleapis.com/f3-public-images-staging/user-avatars/${testUserId}.jpg`,
call createDirectClient() and client.me.updateProfile({ avatarUrl: url }),
assert result.user.avatarUrl equals the staging URL, and perform the same
cleanup by resetting avatarUrl to null; place this alongside the existing tests
so both prod and staging buckets are covered.
🪄 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: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 4ff641e2-a6dc-4e75-addf-a62aaca1878f
📒 Files selected for processing (2)
packages/api/src/router/me/index.tspackages/api/src/router/me/me.test.ts
bb6e3d9 to
bf433bf
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (1)
packages/api/src/router/me/index.ts (1)
63-67:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftThis refine can block PATCH for users still carrying legacy avatar hosts.
If a client posts an unchanged legacy
avatarUrlalong with unrelated profile edits, this now fails validation and rejects the full update. Consider gating strict enforcement behind migration completion or a temporary legacy allow-list.🤖 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 `@packages/api/src/router/me/index.ts` around lines 63 - 67, The avatarUrl zod refine using isAllowedAvatarUrl rejects legacy-host URLs and thus breaks PATCH when clients resubmit an unchanged legacy avatar; modify the validation to permit legacy URLs until migration finishes by either (1) adding a temporary legacy allow-list check (e.g., legacyAllowedAvatarHosts) into the refine or (2) gating the strict isAllowedAvatarUrl enforcement behind a migration flag (e.g., migrationComplete or allowLegacyAvatars) so that avatarUrl validation only requires isAllowedAvatarUrl when migrationComplete is true; alternatively, if you have access to the current user record in the update flow, skip strict validation when the incoming avatarUrl equals the stored avatarUrl to allow unchanged legacy values to pass.
🤖 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.
Duplicate comments:
In `@packages/api/src/router/me/index.ts`:
- Around line 63-67: The avatarUrl zod refine using isAllowedAvatarUrl rejects
legacy-host URLs and thus breaks PATCH when clients resubmit an unchanged legacy
avatar; modify the validation to permit legacy URLs until migration finishes by
either (1) adding a temporary legacy allow-list check (e.g.,
legacyAllowedAvatarHosts) into the refine or (2) gating the strict
isAllowedAvatarUrl enforcement behind a migration flag (e.g., migrationComplete
or allowLegacyAvatars) so that avatarUrl validation only requires
isAllowedAvatarUrl when migrationComplete is true; alternatively, if you have
access to the current user record in the update flow, skip strict validation
when the incoming avatarUrl equals the stored avatarUrl to allow unchanged
legacy values to pass.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 11dd4482-b33c-48fb-af04-4f7745c44edb
📒 Files selected for processing (2)
packages/api/src/router/me/index.tspackages/api/src/router/me/me.test.ts
PR #260 restricts users.avatar_url (write path) to the F3 public-image GCS bucket. Existing rows may hold legacy/external URLs, which would fail the new validation when a user round-trips their current avatar on a profile edit. This adds the one-time data migration that brings existing avatars behind the bucket so the stricter rule has no non-conforming rows left: - packages/shared/app/avatar: single source of truth for the allowlist (ALLOWED_AVATAR_HOST_PATTERN + isAllowedAvatarUrl); the me router now imports it instead of an inline copy, so API and migration can't drift. - packages/db backfill-avatar-urls.ts: dry-run by default; fetches each non-conforming avatar (SSRF-guarded, size/timeout capped), re-encodes to the same 512x512 JPEG the upload route produces, uploads to user-avatars/{id}.jpg, and rewrites avatar_url. --execute applies; --null-failed clears dead URLs; --limit N for canary batches. - scripts: db backfill:avatars + root db:backfill:avatars. Draft: needs a prod dry-run + decisions on dead-URL handling and whether org logos get the same treatment before enabling strict validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🟡 Parked as draft — avatar-migration strategy + script addedPicking up @taterhead247's concern ("would require every image to be migrated") and CodeRabbit's "gate behind a legacy-avatar migration." They're right: the new validation is write-path only, so the risk isn't reads — it's that a user whose stored Yes, this scripts out cleanly — and it's a data backfill, not a Drizzle schema migration (no DDL), so it lives with the other one-off What I added (commit
|
…lator to storage * fix(me): honor EXIF orientation on avatar upload (#315) Android portrait selfies are stored as landscape pixels + an EXIF Orientation tag (6 = rotate 90° CW). The avatar pipeline ran sharp().resize().jpeg() without .rotate(), so it ignored the tag and stripped it on output — saving the avatar rotated 90° CW. - apps/me/src/lib/gcs.ts: extract processAvatarImage() and add .rotate() (auto-applies EXIF orientation) before .resize(). - packages/storage/src/resize.ts: same .rotate() fix (parity; covers the #260 avatar-backfill path). - apps/me/__tests__/lib/process-avatar-image.test.ts: red→green regression test using an orientation-6 landscape fixture (red→top after rotation). - docs/rca/315-avatar-exif-orientation.md: root cause analysis. Closes #315 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(test): add docstrings for avatar test helpers (CodeRabbit coverage) CodeRabbit flagged docstring coverage at 50% (< 80% threshold). Document meanRgb() and the MeanRgb interface so every helper in the new test file carries a docstring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(repo): removed rca writeup for small change as discussed on Slack * refactor(me,storage): i deduped a lot in me and added emulator support to storage * fix(storage): address AI review comments --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: tackle <Damon.Vinciguerra@gmail.com>
PR #260 restricts users.avatar_url (write path) to the F3 public-image GCS bucket. Existing rows may hold legacy/external URLs, which would fail the new validation when a user round-trips their current avatar on a profile edit. This adds the one-time data migration that brings existing avatars behind the bucket so the stricter rule has no non-conforming rows left: - packages/shared/app/avatar: single source of truth for the allowlist (ALLOWED_AVATAR_HOST_PATTERN + isAllowedAvatarUrl); the me router now imports it instead of an inline copy, so API and migration can't drift. - packages/db backfill-avatar-urls.ts: dry-run by default; fetches each non-conforming avatar (SSRF-guarded, size/timeout capped), re-encodes to the same 512x512 JPEG the upload route produces, uploads to user-avatars/{id}.jpg, and rewrites avatar_url. --execute applies; --null-failed clears dead URLs; --limit N for canary batches. - scripts: db backfill:avatars + root db:backfill:avatars. Draft: needs a prod dry-run + decisions on dead-URL handling and whether org logos get the same treatment before enabling strict validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
e994a5c to
c6f9104
Compare
|
🤖 Automated readiness pass
Head: |
…uter Stacked behind PR #163 (feat/me) per @taterhead247's review request: extracts the shared-router half of sweep round 2's R6 (security) so it can be discussed and merged independently of the Me app. Context: The previous schema in packages/api/src/router/me/index.ts accepted any well-formed URL for avatarUrl, letting an authenticated user PATCH the field to an attacker-controlled host. The avatar value is consumed by other apps via this shared router without their own host filter, which creates a tracking-pixel / SSRF surface when consumers pre-fetch the URL server-side. This PR adds a regex refine restricting avatarUrl to storage.googleapis.com/f3-public-images[-staging]/* at the shared router level, plus positive and negative tests. Trade-off the team should weigh before merging: F3 currently has user avatar URLs and region logos pointing at a variety of hosts (Slack, region WordPress sites, etc.). Enforcing a strict host allow-list at the shared router will break write paths for non-GCS-hosted images on other apps that share this router. Possible mitigations the team can choose from: - Backfill non-conforming avatar URLs into the GCS public-image bucket before merging this PR. - Loosen the allow-list to also accept the legacy hosts in use today. - Move the restriction to a write-time enforcement on the apps that upload (the Me app already has its own check), and drop the shared-router enforcement. The Me app's own host check (apps/me/src/app/api/profile/route.ts) remains in PR #163 unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
PR #260 restricts users.avatar_url (write path) to the F3 public-image GCS bucket. Existing rows may hold legacy/external URLs, which would fail the new validation when a user round-trips their current avatar on a profile edit. This adds the one-time data migration that brings existing avatars behind the bucket so the stricter rule has no non-conforming rows left: - packages/shared/app/avatar: single source of truth for the allowlist (ALLOWED_AVATAR_HOST_PATTERN + isAllowedAvatarUrl); the me router now imports it instead of an inline copy, so API and migration can't drift. - packages/db backfill-avatar-urls.ts: dry-run by default; fetches each non-conforming avatar (SSRF-guarded, size/timeout capped), re-encodes to the same 512x512 JPEG the upload route produces, uploads to user-avatars/{id}.jpg, and rewrites avatar_url. --execute applies; --null-failed clears dead URLs; --limit N for canary batches. - scripts: db backfill:avatars + root db:backfill:avatars. Draft: needs a prod dry-run + decisions on dead-URL handling and whether org logos get the same treatment before enabling strict validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c6f9104 to
c31828a
Compare
|
@pstaylor-patrick , let's talk about this next time you're at a Tuesday standup. I still think we need to lock down URLs in our database. I'm not sure this completely solves that. If helps secure Me. But people can still add malicious urls using the standard api users call. And we can't just add the allow list to the users.crupdate, because developers would only be allowed to set the url to that of our bucket, but most (all) of them don't have access to upload images to the bucket. |
Summary
Stacked behind #163 per @taterhead247's review feedback. Extracts the shared-router half of sweep round 2's R6 (security) so it can be discussed and merged independently of the Me app deployment.
Base branch is
feat/me(the head of #163). The diff this PR shows is exactly the R6 host-allow-list code that used to live in #163's sweep-2 commit.Context
The current schema in
packages/api/src/router/me/index.tsaccepts any well-formed URL foravatarUrl. An authenticated user can PATCH the field to an attacker-controlled host, and other apps that consume the avatar value through this shared router (without their own host filter) become a tracking-pixel / SSRF surface when they pre-fetch the URL server-side.This PR adds a regex refine restricting
avatarUrltostorage.googleapis.com/f3-public-images[-staging]/*at the shared router, plus positive and negative tests.Trade-off the team should weigh before merging
@taterhead247 raised this concern on #163. Mitigations the team can pick from:
apps/me/src/app/api/profile/route.ts).What's NOT in this PR (lives in #163)
apps/me/src/app/api/profile/route.ts) -- @taterhead247 explicitly endorsed keeping that.users.meta) from sweep round 2 -- that's a race-condition fix unrelated to host policy.Test plan
users.avatarUrlwould be rejected by the new patternVerification
pnpm lint,pnpm typecheck,pnpm format:checkall passRelated
feat/me); will rebase ontodevif/when Add F3 Me profile manager app #163 lands or is unstacked.🤖 Generated with Claude Code
Summary by CodeRabbit