Verify a Cloudinary media url is our asset, not just a Cloudinary url - #185
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 768cf65737
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ( | ||
| !parsed.pathname.startsWith(`/${cloudName}/`) || | ||
| !parsed.pathname.includes(`/${CLOUDINARY_FOLDER}/`) |
There was a problem hiding this comment.
Require upload delivery before trusting the folder
When Cloudinary fetch delivery is enabled, callers of createPostCallable and the other media-writing callables can still submit a URL such as https://res.cloudinary.com/<our-cloud>/image/fetch/https://attacker.example/petnote/x.jpg. It satisfies both string checks, but Cloudinary serves an attacker-controlled remote asset without using the signed upload pipeline, preserving the size/rate-limit bypass and post-moderation replacement risk this change is intended to close. Validate the Cloudinary resource and delivery segments as an expected image/upload or video/upload URL and verify petnote in the uploaded asset's public ID rather than accepting it anywhere in the pathname.
Useful? React with 👍 / 👎.
TRUSTED_MEDIA_URL_HOSTS is ["res.cloudinary.com"], and validateTrustedHttpsUrl checked the protocol and the hostname and nothing else. A hostname proves the bytes are served by Cloudinary. It does not prove they are ours. Anyone can register a free Cloudinary account, and https://res.cloudinary.com/<their-cloud>/image/upload/... passed that check unchanged. So a client could call createPostCallable directly with a url in their own bucket — never requesting an upload signature at all — and skip the entire pipeline: the 10MB/80MB size caps, the per-user petnote/users/{uid} folder, the signature rate limit. The worse half is ownership. An asset in someone else's cloud stays under their control, so anything that passed a moderation pass could be replaced afterwards at the same url, on every post, avatar, cover image and check-in that referenced it. Same primitive lets one user publish another user's asset url as their own media. Two checks now, and only for res.cloudinary.com: the first path segment is our cloud name, and the path runs through the petnote/ folder our signing callable creates. dicebear and lh3.googleusercontent.com are untouched — they serve generated default avatars and Google sign-in photos, which are not ours to fingerprint by path, and rejecting them would blank the avatar of every user who never uploaded one. NOT enforcing that the folder's uid matches the caller. Family members co-edit a pet's avatar, so uploader and writer are legitimately different people, and reuse inside our own bucket is a far smaller problem than a foreign bucket. The cloud name is read from process.env rather than CLOUDINARY_CLOUD_NAME .value(), so shared.ts does not have to import a secret param; a bound secret is an env var at runtime. The eleven callables that validate media urls now bind it. A callable that reaches the validator without it throws rather than degrading to host-only checking, because a silent downgrade would reopen the hole invisibly. isTrustedHttpsUrl, the read-path variant used by getNotificationActor on triggers as well as callables, stays host-only. It is not an authorization decision — it chooses between a stored avatar and a generated default — and ownership is enforced where the url is written. Urls stored before this are not re-checked; that needs a backfill, not a read-path change. Four new tests fail on the old validator and pass on this one. Three pass on both, as guardrails: our own cloud is still accepted, a never-allowed host is still rejected, and a dicebear avatar still works. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
768cf65 to
4e99cbe
Compare
Photo upload has been failing with Invalid Signature. The server signed four parameters; Cloudinary verifies three. Cloudinary signs only the upload parameters it recognises and silently drops the rest. max_file_size is not one it recognises, so including it in the signed set produced a signature that could never match. Its own error said so — the String to sign it echoed back listed folder, timestamp and upload_preset, and nothing else. This is not a regression from #185, which never touched media.ts or the client upload path. It dates from whenever max_file_size entered the signature and has been broken ever since; nobody noticed because nobody had uploaded a photo through the UI in between. The comment above the limits is what caused this. It claimed they were "enforced on the signature itself so a leaked signature can't be reused to upload a bigger file than we allow" — a reasonable-sounding guarantee that Cloudinary does not offer. Replaced with what is actually true: the limits are advisory, they exist to feed the client-side check, and the only enforceable ceiling is whatever the petnote_image_signed / petnote_video_signed upload presets have configured in the Cloudinary console. The client stops sending max_file_size too. Cloudinary ignores it, so it bought nothing except the appearance that the limit was being transmitted somewhere meaningful. The size check that produces a useful error before a doomed upload stays, and the callable still returns maxFileSize to feed it. Two tests encoded the same false premise and are corrected. One of them — "signs exactly the parameters it returns" — already carried the comment "if the handler ever signs a different set of params than it hands back, Cloudinary rejects every upload". It was right, and it still passed, because it recomputed the same wrong set the handler used. max_file_size was on both sides of that comparison and on neither side of Cloudinary's. Its list is now written as the contract with Cloudinary rather than a mirror of the implementation. NOT VERIFIED HERE: whether the upload presets actually carry a max file size in the Cloudinary console. I cannot read that from the repo. If no limit is set there, then after this change there is no server-side size ceiling at all — only the advisory client check. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
TRUSTED_MEDIA_URL_HOSTSis["res.cloudinary.com"], andvalidateTrustedHttpsUrlchecked the protocol and the hostname and nothing else.A hostname proves the bytes are served by Cloudinary. It does not prove they are ours.
The attack
Register a free Cloudinary account.
https://res.cloudinary.com/<their-cloud>/image/upload/...passes a host allowlist unchanged. CallcreatePostCallabledirectly with that url — without ever requesting an upload signature — and the entire pipeline is skipped: the 10 MB / 80 MB size caps, the per-userpetnote/users/{uid}folder, the signature rate limit.The worse half is ownership. The asset stays under the attacker's control, so anything that survives a moderation pass can be replaced afterwards, at the same url, on every post, avatar, cover image and check-in referencing it. The same primitive lets one user publish another user's asset url as their own media.
The fix
Two checks, and only for
res.cloudinary.com:petnote/folder thatuserFolder()inmedia.tscreatesapi.dicebear.comandlh3.googleusercontent.comare untouched — generated default avatars and Google sign-in photos aren't ours to fingerprint by path, and rejecting them would blank the avatar of every user who never uploaded one.Two deliberate non-goals
Not enforcing that the folder's uid matches the caller. Family members co-edit a pet's avatar, so uploader and writer are legitimately different people. Reuse inside our own bucket is a far smaller problem than a foreign bucket, and the strict version would break a shipped flow.
isTrustedHttpsUrlstays host-only. It's the read path —getNotificationActor, on triggers as well as callables — and it isn't an authorization decision, it chooses between a stored avatar and a generated default. Ownership is enforced where the url is written. Urls stored before this change are not re-checked; that needs a backfill, not a read-path change.How the cloud name reaches the validator — and the gap in the tests
Read from
process.env.CLOUDINARY_CLOUD_NAMErather thanCLOUDINARY_CLOUD_NAME.value(), soshared.tsdoesn't have to import a secret param. A bound secret is an env var at runtime.Eleven callables now bind it —
createPost,createPet,updatePet,createMeetup,updateMeetup,addPlace,addLocationPhotos,submitReview,checkIn,ensureUserProfile,updateUserProfile. A callable that reaches the validator without it throwsinternalrather than degrading to host-only checking, because a silent downgrade would reopen the hole invisibly.Worth knowing before merge: the emulator tests cannot catch a missing binding. They drive handlers through
.run(), which bypasses secret mounting entirely, andsetup.tssetsCLOUDINARY_CLOUD_NAMEdirectly — so a callable I forgot would pass CI and fail in production on the first upload. I derived the list by grepping every transitive path to the four validator entry points rather than by testing, andupdatePostCallableis deliberately absent (it copies an already-validatedpetData.avatarUrland never validates a url itself).A follow-up worth considering: the cloud name is not actually a secret — it appears in every image url the app serves. Moving it to a plain param alongside
CLOUDINARY_FOLDERinplatform.tswould remove the binding requirement and this whole class of mistake. That's a config change, so I haven't done it.Tests
functions/src/__tests__/media-url-ownership.test.ts, 7 tests.Four fail on the old validator, pass on this one:
Three pass on both, as guardrails: our own cloud still accepted, a never-allowed host still rejected, a dicebear avatar still works.
Local run: functions
test:emulator80/80, rules 57/57, lint / build /typecheck:test/typecheck:scriptsclean.Deploy
Functions only — no rules change. The eleven callables gain a secret binding, so they do need redeploying for the check to take effect.
🤖 Generated with Claude Code