Disclosure · Backend as a Service · RLS

When the database is left wide open

Leer en español

The client was a company with an internal management system built on a Backend as a Service (BaaS, in the base44/Supabase style). These platforms expose the database directly to the frontend: security doesn't depend on the app's code but on the access rules configured per entity and per row. And that's where the hole was.

The pattern: "parent" entities protected, "children" not

Walking the API entities, some responded correctly (auth required, or empty via row rules). But several internal entities returned all their records with no authentication:

EntityNo authNote
User / Config / PagoOrdenServicio401Correct
Cliente / Pedido / MovimientoCaja200 []Correct (RLS filters)
PedidoHistorial / PedidoProducto200 + dataNo rule
PagoPedido / PagoOperacion200 + dataNo rule
ChatMensaje / SeguimientoOperacion / DetalleOS200 + dataMissing access control

The key detail: the protected entities (Cliente, Pedido) were fine, but their related entities —the ones hanging off them— had no rule. Anyone knowing the BaaS URL pattern reads the full data:

curl "https://<baas>/api/apps/<appId>/entities/PedidoHistorial"
# -> 200 OK
#    [ { "pedido_id":"...", "usuario_email":"[redacted]@...",
#        "estado_anterior":"Pendiente", "estado_nuevo":"Confirmado" }, ... ]

In total, over 13,000 internal records with personal and financial data: customer emails, payment methods and amounts, and internal conversations. None were test data (is_sample was false for 100% of them).

Why it's so easy to exploit

It needs no authentication, no user interaction, no special conditions: just an HTTP request. And since the API responded with Access-Control-Allow-Origin: *, almost any malicious site could read that data from a visitor's browser and exfiltrate it. The frontend JavaScript "gate" (role === "admin") protects nothing: it's cosmetic.

Root cause and fix

The cause is configuration, not code: row-level access rules weren't applied to all entities. The fix is to write those rules for all of them:

Entity: PedidoHistorial
  read:   authenticated AND (usuario_email == current_user.email OR current_user.role == "admin")
  create/update/delete: authenticated AND current_user.role == "admin"
  • Read: never "public/anonymous"; require an authenticated owner or admin.
  • Write: authenticated + owner or admin.
  • Verify after: repeat the same requests with no token and confirm they return 401 or 200 [] — never 200 with data.
  • Restrict CORS to your own domain and keep internal costs out of the public catalog.

The lesson

On BaaS platforms the database is the attack surface. When access rules are defined per entity, it's very easy to protect the main tables and forget the child ones — which often hold the most sensitive data: payments, chats, history.
← Back to Security