June 16, 2026
READING The JavaScript Gave Me a Cross-Tenant Write + SSRF ๐ฅ
Hey Guys and Welcome Back! ๐
httpzuz
3 min read
TL;DR
A multi-tenant, Supabase-backed SaaS had a "zip my submission files" Edge Function that (1) fetched any URL I handed it (SSRF) and (2) wrote the resulting archive into any workspace's storage folder โ because it trusted a client-supplied path and wrote with the service role, bypassing the Row-Level Security (RLS) that blocks normal uploads. The interesting part isn't the bug, it's the path I took to it. That's what this post is about.
Identifiers (project, workspace IDs, program name) are redacted. Published after coordinated disclosure.
The target in one paragraph
A workspace/CRM dashboard built on Supabase (PostgREST + Storage + Edge Functions) with a Lovable-style React frontend. Tenancy is enforced by RLS: the browser talks directly to https://<project>.supabase.co with a public anon key plus the logged-in user's JWT, and the database decides what each tenant can see. That architecture is the whole story โ so the methodology is built around it.
Step 1 โ Let the client draw the map
In a Supabase/Firebase/Lovable app, the frontend is the API documentation. Every backend call lives in the JS bundle. So instead of fuzzing blindly, I pulled the bundle and grepped for what the supabase-js client invokes:
.from("โฆ")โ tables.rpc("โฆ")โ database functionsfunctions.invoke(\..)โ Edge Functions
The function names were minified into backtick strings; one grep for invoke(\..)dumped the entire Edge Function inventory โ includingadmin-operations,backup-export/backup-import,create-portal-sessionand the one that mattered: zip-submission-files.
NOTE:_ in a BaaS app, enumerate the backend_ from the client_, not by guessing endpoints._
Step 2 โ Read the contract before attacking
The bundle even shipped in-app API docs plus the exact invoke call:
functions.invoke('zip-submission-files', { body: {
files: [{ url, name }], zipName, submissionId
}})functions.invoke('zip-submission-files', { body: {
files: [{ url, name }], zipName, submissionId
}})Two fields stood out immediately:
files[].urlโ a list of URLs the server will fetch.submissionIdโ a path component for where the output is written.
NOTE:_ treat every client-supplied field as a question โ_ what does the server do with this, and does it check it?
Step 3 โ The obvious question: what URL does it fetch?
If the server fetches files[].url to build the zip, can I make it fetch mine? I sent https://example.com/, downloaded the returned (signed) zip, and unzipped it โ it contained Example Domain's HTML.
SSRF confirmed: the server fetches arbitrary URLs and hands me the response inside the archive. (Cloud-metadata / localhost / internal hostnames weren't reachable from the locked-down Deno runtime, so this is external, full-response SSRF.)
Step 4 โ The better question: where does it write?
The success response wrote the archive to task-submissions/<submissionId>/<zipName>, and submissionId came straight from my request body. Two facts collided:
- The output path is attacker-controlled.
- Edge Functions run with the service role โ Supabase's key, which bypasses RLS.
So the real question became: does it check that submissionId belongs to a workspace I'm a member of? I set submissionId to a different workspace's ID and sent it. Response: success: true, with the file written to a path under that foreign workspace.
Step 5 โ Prove it's a real boundary crossing (not a false positive)
This is the step that turns "interesting" into "confirmed", and you can do it from a single session โ let the app's own authorization testify that you're an outsider, then show you crossed the line anyway:
[1] who am I? โ user in WORKSPACE_B only
[2] my workspaces โ [WORKSPACE_B] // victim ABSENT
[3] my membership in A โ [] // not a member
[3b] can I read A? โ [] // RLS denies
[4] write to A via fn โ success, path: A/poc.zip // it wrote anyway
[5] direct upload to A โ 400 "row-level security policy" // boundary is real[1] who am I? โ user in WORKSPACE_B only
[2] my workspaces โ [WORKSPACE_B] // victim ABSENT
[3] my membership in A โ [] // not a member
[3b] can I read A? โ [] // RLS denies
[4] write to A via fn โ success, path: A/poc.zip // it wrote anyway
[5] direct upload to A โ 400 "row-level security policy" // boundary is real[2]/[3]/[3b] are RLS answers โ the server itself states I have no relationship to workspace A. [5] proves a direct write there is correctly denied. Yet [4] writes my (SSRF-fetched) content into A's storage. The contradiction is the vulnerability โ and no second account was needed to prove it.
Impact
Any authenticated user can write/overwrite arbitrary, attacker-controlled files into any tenant's storage bucket (data tampering/poisoning; potential overwrite of legitimate files; cross-tenant content delivery if those files are served back to that workspace's users) โ plus SSRF.
Methodology, in four lines
- The client is your API spec โ enumerate edge functions/tables/RPCs from the bundle.
- Every input is a question โ is it fetched? trusted? checked against my scope?
- Follow the service role โ admin/service-role code paths are where RLS dies; that's where tenant isolation breaks.
- Prove the boundary from one session โ make the app's own authz attest you're an outsider, then show you crossed it. That's what makes it undeniable.
Thanks for reading! ๐ If this helped, share it with the security fam. See you in the next one ๐