TYPE5AFE

Security ResearcherM. Indra PurnamaJakarta, ID (UTC+7)Document: PORTFOLIO-2026-11Status: ACTIVELast-Modified: 2026-11-07Canonical: type5afe.ee

How a label key got me the whole database

Date2026-11SeverityCRITICALTagssqli, graphql, postgresql

The platform let you tag the things in your workspace with labels, small key/value pairs like env=prod, and filter on them through its GraphQL API. A filter like that is two client strings ending up in a database query. I wanted to know if both of them were treated the same way.

I can’t name the vendor or the product. Field names, identifiers and output below are changed or trimmed. The bug itself is as I found it.

I started with a request that should just work:

query ($ws: String!, $labels: [LabelInput!]) {
  resources(workspace: $ws, first: 10, labels: $labels) {
    totalCount
  }
}
{ "labels": [{ "key": "env", "value": "prod" }] }

It did, and one resource came back. Then I changed only the key, to a'), and got this instead of data:

ERROR: syntax error at or near "' = $4) AND "resources"."state" = $5 ..." (SQLSTATE 42601)

That message told me two things. The API was passing raw PostgreSQL errors back to the client, and the error quoted the part of the query it choked on. From that fragment I could rebuild the WHERE clause:

(<jsonb lookup> '<KEY>' = $4) AND "resources"."state" = $5

The value had become $4, a bound parameter. The key had been pasted in between two single quotes.

I wanted to be sure it was the key and not just quotes in general, so I moved the quote into the value: key a, value b'. No error. Then I counted quotes in the key. env' broke, env'' was fine, env''' broke again. An odd number breaks the statement and an even number doesn’t, which is what a string concatenated into SQL with no escaping looks like.

An error proves the injection. I wanted the database to evaluate something for me. The awkward part was the server’s own ' = $4 after my key. I couldn’t remove it, so I had to give it something to close. My key ended the real comparison, added my condition, and then opened an empty string for the server’s quote to close:

key:   env' = 'prod' AND 1=1 AND '
value: ""

The server put that together as:

(<jsonb lookup> 'env' = 'prod' AND 1=1 AND '' = $4) AND ...

I sent the value as an empty string, so '' = $4 was always true and only the condition in the middle decided the result. With 1=1 the response said "totalCount": 1. With 1=2 it said 0. From then on, the count in the response was the answer to whatever SQL I put in that slot.

Next I tried time. I put (SELECT 1 FROM pg_sleep(5))=1 in the slot and expected the request to take five seconds. It took about fifteen, against half a second for the 1=1 request, so the condition was running around three times per request. The exact number didn’t matter. A delay channel meant that even with the errors and the count both hidden, the data could still come out one slow bit at a time.

It didn’t have to, because the errors were still there. If you cast a string to integer, PostgreSQL complains, and the complaint includes the string:

key: env' = 'prod' AND (SELECT CAST(version() AS int)) = 1 AND '

ERROR: invalid input syntax for type integer: "PostgreSQL 18.x on x86_64-pc-linux-gnu, ..." (SQLSTATE 22P02)

That’s a whole value in one request. I did the same with current_user, which came back as postgres, and with current_database(), which gave me the application’s database.

Before writing it up I checked whether anything else used the same filter. The autocomplete endpoint behind the same search box takes its own label filter, and a') as a key there gave me the same 42601 error. My guess is that both share one query builder, so patching only the endpoint I’d started on wouldn’t have closed it.

Then I looked at what postgres was allowed to do. It was a member of the managed provider’s superuser-equivalent role. It didn’t have BYPASSRLS, but it held so much privilege over the application’s own tables that row-level security wasn’t going to stop it.

So any signed-in user, from a filter the UI used every day, could read every table in the application database, including every other tenant’s data, and could write to them too. With three separate ways to get data out, hiding the error messages alone wouldn’t have fixed it.

The code was close to right. Someone had bound the value. My guess is the key went in by hand because keys feel like identifiers, and identifiers can’t be parameters. But a JSONB key isn’t an identifier. It’s a string, and it binds like one:

-- instead of:  labels ->> '<KEY>' = $4
labels ->> $3 = $4
-- or, and a GIN index can serve this one:
labels @> jsonb_build_object($3::text, $4::text)

When something really is an identifier, like a sort column, check it against a list of allowed names instead.

It’s also why I put a quote in every field of a filter input, not only the obvious one. Here the value, the field everyone tests, was the safe half.

Two other things turned a bug into a serious one. Returning raw database errors gave me the query’s shape for free and let me read any value in a single request. Connecting as a superuser meant the injection reached everyone’s data. A role that owned nothing, with RLS forced on the tenant tables, would have contained the same bug far better.

[ back to notes ]