Hasura GraphQL Engine turns a Postgres database into a GraphQL API and layers a declarative, row-level permission system on top: for each role you write a boolean row filter, and Hasura enforces it on every read of that table. Computed fields (database functions exposed as fields) inherit those permissions automatically. When a computed field returns SETOF a permissioned table, the console tells the operator the field’s access is auto-derived from the returning table’s permissions, and refuses to let you set a manual one. We found one code path where that inheritance silently does not happen. A where clause over such a computed field compiles to a SQL EXISTS subquery carrying the caller’s predicate and no row filter, which turns the field into a boolean oracle. A low-privileged role can ask whether a hidden row matches a condition and read the answer off a row it is allowed to see, then bisect its way to the hidden row’s contents. Reported to Hasura and fixed in 2.49.2 and 2.45.5 (CVE-2026-54698, CWE-863, High, CVSS 7.7).
IMPORTANT
Who’s exposed. You’re affected if you run Hasura GraphQL Engine on a Postgres-family backend (any 2.x before 2.49.2, or 2.45.0 through 2.45.4), you expose a computed field that returns SETOF a table, and that returning table has a row-level select permission meant to hide rows from some role. That is exactly the pattern Hasura documents for computed fields, so the precondition is a supported, common configuration rather than an exotic one. You are not affected if you define no SETOF-table computed fields, if the returning table has no row-level restriction (nothing is hidden, so there is nothing to leak), or if you run only on a backend where computed-field boolean expressions are never built: BigQuery, MS SQL, and Data Connector reject them outright, and CockroachDB never surfaces functions at all. The attacker needs only a normal, low-privileged role: no admin secret and no elevated grant.
The tell: the filter is applied on every other path
The most useful signal in this finding is that the row filter is not missing from Hasura. It is threaded, correctly, through every other way one entity’s rows can be referenced from another’s boolean expression. The Postgres boolean-expression translator ANDs the returning entity’s row filter into the subquery it builds for a relationship, for an aggregation predicate, and for a nested object, and the permission compiler does the same when a computed field appears inside a permission definition. Only the constructor for a computed field in a query where clause omits it.
| Cross-entity path in a boolean expression | Returning row filter applied? |
|---|---|
Relationship where (AVRelationship) | yes |
Aggregation predicate where (AVAggregationPredicates) | yes |
Nested object / native-query relationship (AVNestedObject) | yes |
| Computed field inside a permission definition | yes |
Computed field where (AVComputedField / CFBETable) | no |
Selection is filtered too: users { all_docs { ... } } correctly returns only the documents the role may see, because the selection IR threads the returning table’s permission (_asnPerm). The where translation is the one place that reads the same relationship and forgets the filter. The asymmetry is the vulnerability: not a wrong filter, a filter present on four sibling paths and absent on the fifth.
The bug, in one constructor
The translator turns a where over a computed field into an EXISTS subquery against the function’s result set. For a relationship it builds the same shape, but ANDs in the remote table’s resolved row filter (spiFilter); for the computed field it builds the subquery from the user’s predicate alone. In Backends/Postgres/Translate/BoolExp.hs (read in v2.49.0), the AVRelationship case carries the permission boolean expression into the join, while the AVComputedField / CFBETable case does not. The asymmetry starts one layer up, in the schema build: the relationship path fetches the remote table’s select permission (remoteTablePermissions = maybe annBoolExpTrue spiFilter (tableSelectPermissions roleName remoteTableInfo)), whereas the computed-field path resolves ReturnsTable table to CFBETable table and never fetches the returning table’s filter at all. The value the fix needs already exists (the selection IR carries it); it simply is not read on this path.
It is visible on the wire. The attacker is a normal role (user), never admin, and $2 is the id of a row that role is not permitted to see:
-- Computed-field WHERE (vulnerable): the documents row filter is absent,
-- the inner predicate is the attacker's alone.
SELECT ... FROM "public"."users"
WHERE (("public"."users"."id") = (($1->>'x-hasura-user-id')::integer))
AND EXISTS (
SELECT 1
FROM "public"."user_all_documents"("public"."users".*) AS "__be_0"
WHERE (("__be_0"."id") = (($2)::integer)) AND ('true') AND ('true'));
-- Relationship WHERE (secure, for contrast): the documents row filter IS ANDed in.
... EXISTS (
SELECT 1 FROM "public"."documents" ...
WHERE ... AND (("documents"."owner_id") = (($1->>'x-hasura-user-id')::integer)
OR ("documents"."is_private") = 'false') ...)
From a missing filter to the row’s contents
On its own, a missing filter inside an EXISTS leaks one bit: does a hidden row matching my predicate exist? That bit is enough. Take a documents table whose row select permission for role user is (owner_id = X-Hasura-User-Id) OR (is_private = false) (the standard “your rows plus the public ones” filter) and a computed field all_docs on users that returns SETOF documents. As role user, from Alice’s token and never the admin secret:
# Existence oracle over a row Alice may not see (doc 300 = an admin private document):
{ users(where: { all_docs: { id: { _eq: 300 } } }) { id } } # -> [{ id: 1 }] LEAK (should be [])
{ users(where: { all_docs: { id: { _eq: 999 } } }) { id } } # -> [] control
The parent row (Alice) is one she is allowed to see, so the answer is delivered to her. Because the oracle composes with _and, _or, _not, and the range and pattern operators, the hidden row’s role-selectable columns come out in full, not one bit at a time:
# Blind extraction: does the hidden row's title start with "Admin"?
{ users(where: { all_docs: { _and: [ { id: { _eq: 300 } },
{ title: { _like: "Admin%" } } ] } }) { id } }
Numeric and orderable columns fall in O(log n) comparisons via _gt / _lt bisection; text falls to _like prefix search. We recovered an admin private document’s title and body end to end in a controlled lab and checked them against an admin-secret oracle. The ceiling is confidentiality only, read only, row scoped: not a write, not an integrity break, not code execution. It is efficient, complete recovery of the role-selectable columns of rows the filter exists to hide. We rate it High, CVSS 7.7, AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N: a network caller with a low-privileged role and no user interaction, crossing from the GraphQL authorization layer into rows of a different security authority (other tenants, or the admin), with full recovery of those rows’ role-selectable columns.
Where the oracle fires
The gap is in shared translation, not in any one handler, so it surfaces wherever a computed-field where can appear:
queryselection,_aggregate, and livesubscription.update_*anddelete_*where: a role holding only an update or delete permission on the parent, with noselecton the root field, still leaks.affected_rowsis the oracle bit. Cross-row writes stay blocked (the mutation’s own permission filter holds), so this is a read oracle, not an integrity break.- Nested relationships: reached via
documents(where: { owner: { all_docs: { id: { _eq: 300 } } } }), so a role without theusersroot field but withdocumentsand theownerrelationship still gets there.
Where this applies, and where it doesn’t
The same translator serves the whole Postgres database family, so the vulnerable code is identical on each. What changes from one to the next is only whether a SETOF-table computed field can be defined in the first place:
| Database | Affected? | Why |
|---|---|---|
| Vanilla PostgreSQL | yes | Confirmed on a live instance, and the database most Hasura deployments run on. |
| CockroachDB | no | Confirmed clear on a live instance. Hasura never exposes Cockroach functions, so the computed field can’t be defined to begin with. |
| Citus | probably | Citus shares the same schema and translator and does expose functions, so the computed field looks definable. We confirmed the code path by reading it, not by running it against a live Citus cluster. |
| BigQuery, MS SQL, Data Connector | no | These reject computed fields in a where clause outright. |
If you run Hasura on Citus, treat yourself as affected until you’ve checked: the vulnerable path is there, and the only thing we stopped short of is a live end-to-end reproduction.
Remediation
If you maintain the code. Thread the returning table’s resolved row filter into the computed-field path the way every sibling path already does. In the Postgres translator, the CFBETable case should AND the returning table’s spiFilter into the inner EXISTS, mirroring the relationship case; in the schema build, the ReturnsTable branch should fetch the returning table’s select permission the same way the relationship path fetches remoteTablePermissions. The selection IR already carries this filter, so the value exists; it just is not being read on this path. As defense in depth, refuse to compile a computed-field boolean expression when the returning table’s row filter cannot be resolved, so a future path cannot silently skip it again. This is the shape of the upstream fix: row-level select filters now apply to computed-field boolean-expression paths, in line with the relationship and aggregation predicates.
If you operate it. Upgrade to 2.49.2, or 2.45.5 on the 2.45 line. Until you can:
- Find your exposure: any computed field that returns
SETOFa table which carries a row-levelselectpermission is a candidate. A computed field over a table with no row restriction leaks nothing. - If you cannot upgrade immediately, drop or untrack those computed fields, or widen the returning table’s row filter so nothing sensitive depends on it. There is no permission toggle that re-adds the missing filter on this path in an affected version.
- The oracle is quiet by design: the leak rides on
wherepredicates that return normal, authorized parent rows, so watch for unusual volumes ofwhereclauses that reference aSETOF-table computed field with tight equality or range predicates.
Disclosure
- Software: Hasura GraphQL Engine, any 2.x before 2.49.2 and 2.45.0 through 2.45.4 (reviewed against v2.49.0 CE). The path is long-standing across the 2.x line.
- Class: CWE-863, incorrect authorization (row-level permission bypass).
- Severity: High, CVSS 7.7 (
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N). - CVE: CVE-2026-54698; advisory public 2026-06-29.
- Fix: 2.49.2 (2026-06-11), backported to 2.45.5 (2026-06-12). Reported privately to Hasura with the fix and the backend analysis above.
- Reported by: Cipher / Causal Security.
All testing was in a self-contained lab against an instance we controlled; the users, documents, and the computed field are synthetic.
Why we audit the promise, not the path
Hasura makes an explicit promise about this field: the console tells the operator a computed field’s access is auto-derived from its returning table, and refuses a manual permission on the strength of that. A promise like that is not enforced in one place. It is re-implemented, by hand, on every code path that can reference the field, and it holds only as strongly as its least-covered path. Selection honored it. Relationships honored it. Aggregations honored it. The permission compiler honored it. The where translator did not, and one uncovered path is enough to turn a promised-safe field into an oracle. The method that finds this is not reading the handler that looks suspicious. It is taking a guarantee the system claims to make everywhere and enumerating every path that has to re-assert it, then hunting the sibling that forgot. Derived permissions, inherited filters, cascaded grants: any authorization promised to apply automatically is only as good as its most-forgotten path, and those paths are exactly where we look.
This work sits in our API security practice and is part of our ongoing vulnerability research into the open-source infrastructure that everyone else builds on.