surrealdb: PATCH copy/move/test JSON Pointer operations bypass field-level SELECT permissions
What fixes it
- 3.3.0surrealdb, rustGitHub
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
A caller with table-level write permission on a record — UPDATE, and equally CREATE, UPSERT, RELATE, or INSERT — could read a field hidden from them by a field-level PERMISSIONS FOR select clause by moving it through a JSON Patch data clause.
The SET, CONTENT, MERGE, and REPLACE clauses compute their right-hand sides against a permission-reduced view of the record, so a restricted field reads as NONE. PATCH was the exception: its operations were applied to the full record loaded from storage, and the copy/move from pointer and the test path pointer were resolved against that full record. A previous fix (GHSA-fpxg-5xmv-922m, patched in 3.1.0) rejected only the empty root pointer; any pointer naming a field, a nested field, an element of an array, or an object that contained a restricted field still resolved against the unreduced record.
So a caller could copy a value they were not allowed to read into a destination field they chose, receive it in the statement result, and persist it where anyone who can select that destination can read it. move did the same and additionally removed the source field. test reported the value it found in its mismatch error, acting as a direct disclosure oracle without any durable write.
For example, given DEFINE FIELD secret ON person PERMISSIONS FOR select NONE, a record user with table-level update permission could run:
UPDATE person:1 PATCH [{ op: 'copy', from: '/secret', path: '/leak' }];
-- result: [{ id: person:1, leak: 'shh', ... }]and read secret back under the name leak.
Impact
This affects any deployment that uses field-level PERMISSIONS FOR select to hide fields from principals who nonetheless hold table-level write permission — for example multi-tenant tables, user-profile tables, or partial-field redaction where record users can update rows.
What an attacker can do:
- With table-level write permission (
UPDATE,CREATE,UPSERT,RELATE, orINSERT) on a table, read the value of a field on that table denied to them by a field-levelPERMISSIONS FOR selectclause, by copying it into an unrestricted destination and reading it back, or by usingtestas a disclosure oracle. - Persist the copied value into a destination field, where it is then readable by anyone who can select that field.
- With
move, additionally remove the restricted source field as part of the same operation. - Reach a restricted field named directly, nested inside an object, inside an array element, or by naming an ancestor object or array that contains it.
What it can't do:
- Reach records, tables, namespaces, or databases the caller cannot already access — this issue does not bypass table-level permissions, and the disclosure is confined to the field-permission layer within records the caller can already reach and write.
- Escalate to namespace- or root-level privileges.
- Bypass anything through the
addorchangeoperations, or through thevalue::patchfunction, which operates only on values the caller already supplies.
Patches
A PATCH copy/move from pointer, and a test path pointer, are now rejected when they read a field the caller may not select. Each pointer is judged by comparing what it resolves to on the record against what it resolves to on the permission-reduced view of that record; any difference — whether the pointer names the restricted field directly or an object or array that contains it — fails the statement rather than carrying the value out. The check is applied for every statement type with a PATCH clause and shares the pointer-resolution logic used to apply the patch, so a numeric token that reaches into an array is judged at the element the patch actually reads, and a shift from an earlier operation in the same patch cannot move a restricted field into range of a later pointer. The add and change operations are not gated, because each writes its result back to the path it read and so carries nothing out of the field.
- Versions 3.3.0 and later are not affected by this issue.
Workarounds
Users unable to patch should consider the following workarounds:
- Do not grant table-level write permission (
UPDATE,CREATE,UPSERT,RELATE, orINSERT) to principals who must not read one of the table's SELECT-restricted fields. Restrict at the table level instead, or move sensitive fields to a table those principals cannot write. - Where less-trusted principals only need to write through controlled paths, route their writes through
DEFINE APIendpoints or functions, and deny them arbitrary queries with--deny-arbitrary-query(envSURREAL_CAPS_DENY_ARBITRARY_QUERY).
References
- SurrealQL Documentation — UPDATE statement
- SurrealQL Documentation — DEFINE FIELD
- SurrealQL Documentation — DEFINE TABLE … PERMISSIONS
fix(doc): reject PATCH read pointers into SELECT-restricted fields(included in SurrealDB 3.3.0)fix(expr): resolve a JSON Patch array index as the element it names(included in SurrealDB 3.3.0)
Acknowledgements
Thank you to Yanhaoxi for reporting this issue.
Text from GitHub Advisory Database
Affected versions 1
- surrealdb, rust< 3.3.0GitHub