Repository navigation
json_tree: a value read with -> or a subtree walk is not checked to be the path asked for #1355
Description
Background
EQL (Encrypt Query Language) is the SQL library we install into a customer's PostgreSQL database. EQL v4 has a new column type, public.eql_v4_json_tree, that stores an encrypted JSON document so the database can answer JSON queries without the key. It landed on next in #1250, #1288, #1325, #1329 and #1336. Stack Encrypt, our Rust encryption engine (packages/stack-encrypt), writes these documents and reads the database's answers back.
A stored document is a list of entries, one per JSON node. Each entry has a path selector s (a keyed hash of the node's path, the same in every row), the node's ciphertext c, and a few search fields. Two kinds of read hand entries back to the client:
doc -> selector(path extraction) returns the one entry at a path, for example$.salary.- The subtree walks (
eql_v4.json_tree_subtree,json_tree_subtree_at) return the entries under a node, which the client puts back together into a JSON value.
Problem
The client decrypts each returned entry under the s and the position j that the database sends back with it, and never checks that s is the selector it asked for.
What the ciphertext authenticates (entry_aad in packages/stack-encrypt/src/json/derive.rs): the keyset, the column, the row's data key, the entry's own selector s and its position j. So any entry sealed in that row opens under its own s and j. Step by step:
- The client asks
doc -> s($.salary). - A dishonest database answers with the row's
$.bonusentry, together with that entry's ownsandj. - The client decrypts it. It opens, because it is a genuine entry of the row.
- The client hands the caller the bonus as the salary. Nothing says otherwise.
The database can also answer with the sealed root entry, which holds the whole document when the root is sealed (the default), so a request for one field returns every field. The walks have the same gap: each returned row opens under its own s, so the database can splice in entries from elsewhere in the same row, reorder them, or leave rows out, and the client rebuilds whatever it is given.
Who hits it: anyone who treats a json_tree read as the value at the path they asked for. Nothing catches it today. The design record says so (docs/plans/2026-10-17-json-tree-index.md, Phase 4, "Open for the owner: an extracted entry opens under the selector the server returns with it"), and so does the leakage page (packages/eql/docs/reference/json-tree-leakage.md, "What is authenticated").
Verified: JsonNode's DecryptInto<Value> (derive.rs) opens self.entry under self.entry.s with no comparison; JsonSubtree's opens every row the same way; eql-bindings' JsonTreeEntry and JsonTreeSubtree (packages/eql/crates/eql-bindings/src/v4/json_tree.rs) pass straight through to them.
Proposal
- Make the selector the client derived open the entry
->returns:selector.open(entry)checks that the entry'ssis that selector and refuses anything else, before any key is retrieved, with its own error (the database answered a different question; the entry itself is genuine, so this is neither a failed decryption nor a type mismatch). Remove the way to open a returned entry without that check, instack-encryptand ineql-bindings. - Open walk rows only through the key that keyed the walk (
key.open(rows), which already exists for the budget fallback), and check every row: the node a gated walk reaches has the selector of the key's path and the label the path names; every other row has the selector its parent's path plus its own label gives; rows follow each other in the document without gaps and at the depth their place says. Refuse a row that cannot be placed. - Say what this does not cover: the same path from another row of the same column still opens, by design (ADR 0010,
packages/stack-encrypt/docs/adr/0010-what-a-stored-value-authenticates.md, and the "Known non-findings" inAGENTS.md). Update the design record and the leakage page. - Tests: refusals for another path's entry, the sealed root, a walk row whose selector does not fit its parent, a reordered or orphaned row; honest answers still open; and a PostgreSQL test that tampers with a real stored row and shows the client refuses what
->and the walks return.
There are no Go, TypeScript or protect-ffi readers of json_tree yet; #1352 (the Go reader) will need the same checks.
Activity
docs(plans): name the pull request that took the extracted-entry check