Skip to content

fix(proxy): stop the JSON selector fusion leaking plaintext (CIP-3682) - #435

Open
freshtonic wants to merge 12 commits into
chore/test-ports-from-envfrom
james/cip-3682-json-selector-fusion-plaintext-leak
Open

fix(proxy): stop the JSON selector fusion leaking plaintext (CIP-3682)#435
freshtonic wants to merge 12 commits into
chore/test-ports-from-envfrom
james/cip-3682-json-selector-fusion-plaintext-leak

Conversation

@freshtonic

Copy link
Copy Markdown
Contributor

Closes two independent ways the N:1 JSON value-selector fusion sent plaintext to the database. Both were known and both were covered by #[ignore]d tests asserting the intended behaviour; those tests are now un-ignored and passing.

Stacked on #434, which this PR targets. Review that one first — it is three lines of substance.

1. A chained accessor shipped the selector in cleartext

SELECT id FROM encrypted WHERE encrypted_jsonb -> 'nested' -> 'string' = $1

emitted eql_v3.jsonb_contains(encrypted_jsonb -> 'nested', '{…}'). RewriteJsonValueSelectorEq built the containment call from a container taken from the original AST: for a single-level accessor that is the bare column, which is what the rewrite wants, but for a chained accessor it is itself a -> expression. So 'nested' went to PostgreSQL as plaintext, and native jsonb -> ran against the encrypted payload, returning 0 rows.

This was not the one-line container swap it resembles. The needle is keyed on the path, so containment against the bare column has to be keyed on the whole path. A chain has no intermediate values the database can hold — the payload between steps is encrypted — so col -> 'a' -> 'b' is one path, $.a.b, into one document.

The path is therefore now a sequence of steps. JsonSelectorSource changed from enum {Literal, Param} to a struct holding Vec<JsonSelectorSegment>, each step independently a literal or a placeholder, so mixed chains like col -> 'a' -> $1 -> 'c' = $2 work. A shared syntactic walker decomposes an expression into (root container, selectors root→leaf) and is used by both sides: inference collects every selector and declines the whole fusion if any step is unresolvable (a partial path is not a path), and the rewrite strips the chain back to its root. Every placeholder step is reported by OutputParamSource::inputs(), so ParamPlan::check_covers still catches a dropped input.

col -> 'a' -> 'b' and jsonb_path_query_first(col, '$.a.b') key the same needle, and single-selector behaviour is unchanged by construction.

Incidental fix: the chain walker matches accessor function names. The previous code matched any two-argument call, which walked recursively would have read the second argument of coalesce(a, b) as a selector.

2. A NULL selector param forwarded the value in cleartext

WHERE encrypted_jsonb -> $1 = $2 with $1 bound to NULL: selector extraction yielded nothing, encryption was skipped, and the rebuild path passed the client's raw bytes for the value operand straight through.

Fixed as an invariant rather than a special case: in Bind::rewrite, an output param the plan says must be encrypted but for which no ciphertext was produced is bound NULL. That covers NULL selector, NULL step of a chain, and NULL value with one rule, and matches SQL semantics (col -> NULL = x is NULL, so no rows).

Proof at the database

With log_statement=all, the statement PostgreSQL logged for the chained-accessor query:

execute s1: SELECT id FROM encrypted WHERE eql_v3.jsonb_contains(encrypted_jsonb, $1::JSONB::eql_v3.query_json)
DETAIL:  Parameters: $1 = '{"sv": [{"s": "a47e57cc3bf88c12e6a2f1e8abea7ce6"}]}'

Bare column, no -> 'nested', and nested appears nowhere in the log. The NULL-selector case logs Parameters: $1 = NULL, where it previously logged the client's plaintext value.

Testing

  • Integration: 356 run, 356 passed, 0 failed (serial, against a dedicated PostgreSQL and Proxy). All 7 fusion-gap tests pass.
  • eql-mapper: 144 passed. The previously-#[ignore]d chained_json_accessor_does_not_emit_the_plaintext_selector now asserts the exact emitted SQL, plus new coverage for depth 3 and 4, ->>, mixed spellings, jsonb_path_query_first, value-on-the-left, <> negation, per-step path recording, an unresolvable step being rejected, and unit tests for the chain walker including the coalesce case.
  • Ignored-test count went 21 → 19: this PR un-ignores two acceptance tests and adds none.
  • cargo fmt --all --check and cargo clippy --all --all-targets -- -D warnings clean.

Run the suite with --test-threads=1: clear() truncates shared tables, so parallel execution produces meaningless failures.

Acknowledgment

By submitting this pull request, I confirm that CipherStash can use, modify, copy, and redistribute this contribution, under the terms of CipherStash's choice.

`WHERE col -> 'a' -> 'b' = $1` on an encrypted JSON column emitted
`eql_v3.jsonb_contains(col -> 'a', <needle>)`. The rewrite took its
container from the original AST, which for a single accessor is the bare
column — but for a chain is another accessor. So the plaintext field name
`a` shipped in the statement text PostgreSQL received, and native jsonb
`->` was applied to an encrypted payload, which matched nothing.

Swapping the container alone would not fix it: the needle is keyed on the
path, so containment against the root column has to be keyed on the WHOLE
path (`$.a.b`), not on the last step. A chain is one path into one
document — the intermediate values have no independent existence, because
the payload between the steps is encrypted and cannot be traversed.

So the path becomes a sequence. `JsonSelectorSource` now holds the steps
of the chain, each independently a literal or a placeholder, and the
inference walks the accessor chain to collect them; the rewrite strips the
whole chain back to its root. One unresolvable step declines the fusion
exactly as one unresolvable selector did before — a partial path is not a
path — and every placeholder step is an input the param plan consumes, so
none is silently dropped.

The chain walk only recognises the accessor spellings by name
(`->`, `->>`, `jsonb_path_query`, `jsonb_path_query_first`). Accepting any
two-argument call, as the previous single-step match did, would read the
second argument of `coalesce(a, b)` as a selector and strip the call.

Refs CIP-3682
…step

The mapper now hands the proxy the STEPS of a JSON accessor chain rather
than a single selector, because a chain is one path into one document.
Resolve each step — a literal from the SQL or a bind param read straight
off the wire — and compose them into one eJSONPath before keying the
value-selector needle on it.

Composition is the same normalisation a lone selector always got, applied
per step and spliced onto the path so far: a step already written as a
path (`jsonb_path_query_first(col, '$.a') -> 'b'`) is rooted at the path
so far rather than at a second `$`, and a subscript step keeps its
bracket. `col -> 'a' -> 'b'` and `jsonb_path_query_first(col, '$.a.b')`
therefore key the same needle, which is what makes the two spellings of
one path interchangeable.

The chained-accessor integration tests are un-ignored, and assert on what
the DATABASE holds: they read the stored row on a connection that bypasses
Proxy, so a payload carrying any field name or value the client wrote in
the clear fails the test.

Refs CIP-3682
`WHERE col -> $1 = $2` with `$1` bound NULL builds no needle: there is no
path to key the value selector on, so `$2` is never encrypted. The Bind
rebuild then forwarded the param it was built around unchanged — which for
this shape is the client's PLAINTEXT comparand. The value crossed the wire
in the clear, and landed in the server log when the column's domain CHECK
rejected it.

An output param the plan says must be encrypted, but for which no
ciphertext was produced, is now bound NULL instead of inheriting bytes.
That is exactly what the SQL means — `col -> NULL = x` is NULL, so the
predicate matches nothing — and it holds the invariant in one place rather
than enumerating the ways a needle can fail to build (NULL selector, NULL
step of a chain, NULL value). An already-NULL param is left alone rather
than dirtied, so a Bind message that has not changed is not re-sent.

Refs CIP-3682
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5439591a-39e9-4f5f-8d85-94b7a74f8582

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@freshtonic
freshtonic requested a review from tobyhede July 30, 2026 01:15
`stored_payload` connected to `PG_PORT` to read the row back on a
connection that bypasses Proxy. `PG_PORT` is the non-TLS PostgreSQL on
5532, but the TLS suite CI runs puts Proxy in front of `postgres-tls` on
5617 — so the read went to a different database, found no row, and the
test panicked indexing an empty result rather than asserting anything.

It passed locally only because a single-database setup makes the two
ports the same, which is exactly the condition that hid the bug.

Now uses `get_database_port()`, which prefers `CS_DATABASE__PORT` and
falls back to `PG_PORT` — the same helper `query_direct_by`,
`reset_schema` and the resilience tests already use for this. This test
was the only place in the suite reaching for `PG_PORT` directly. The
connection is TLS now too, matching every other direct-to-PostgreSQL
path.
`json_accessor_chain` matched `Expr::BinaryOp` and the accessor functions
but never `Expr::Nested`, so a single pair of brackets stopped the walk.

That is not just a missed fusion, it is the CIP-3682 leak again by
another route. On `(j -> 'foo') -> 'bar' = $1` the walker stopped at the
bracket, took the parenthesised accessor to be the ROOT container, and
emitted:

    eql_v3.jsonb_contains((j -> 'foo'), $1::JSONB::eql_v3.query_json)

The plaintext selector 'foo' ships to PostgreSQL, native jsonb -> is
applied to the encrypted payload, and the needle is keyed on `$.bar`
when the real path is `$.foo.bar` — so it matches nothing either. Outer
brackets instead declined the fusion altogether and fell through to
whole-document equality over doubly-nested `->` calls.

Parentheses now unwrap at the root, at every step, and in the selector
position, so every bracketing of the same query decomposes to the same
root and the same path. `Expr::Nested` carries no meaning beyond
recording that the author typed brackets; every consumer of a chain
wants what is inside them.

Both the inference hook and the rewrite rule reach chains through this
one function, so both are fixed by it.
`->` was declared `<T>(T -> <T as JsonLike>::Accessor) -> T`, so the
result of an extraction had the SAME type as the document it came from.
An extracted SteVec entry carries no `sv` array, so traversing one
selects nothing — but nothing in the type system said so, and the only
thing standing between a user and a wrong answer was a syntactic walker.

A walker can always be defeated by moving the halves apart:

    SELECT a -> 'foo' FROM (SELECT j -> 'bar' AS a FROM t) s

`a` is syntactically far from the `->` that produced it, so the path
`$.bar.foo` cannot be composed. It emitted a second entry-scoped accessor
over an entry and returned NULL, silently. Written as a predicate it was
worse: the fusion claimed it, rooted a needle at `a`, and keyed it on
`$.foo` when the real path was `$.bar.foo` — wrong rows, no error.

`EqlTerm::JsonExtracted` now types that result, with no capabilities at
all, so no operator or function can require anything of it. A type
crosses a subquery boundary where a syntactic pattern does not, which is
the whole reason to carry this in the type system.

Inference has to be fusion-aware for that to work. The operator
declaration is compositional — it sees only its immediate left operand —
but a chain is not: `j -> 'a' -> 'b'` is ONE path into ONE document.
Typing it step by step would make the first link `JsonExtracted` and the
second link fail, rejecting every chain including the ones that fuse
correctly. So the `Arrow`/`LongArrow` rule consults the chain BELOW the
node and asks whether its ROOT is a document. Within one expression the
walker always reaches the root, so an extracted intermediate is fine;
across a subquery it cannot, the root is itself extracted, and the access
is refused with a message naming the fix.

`eql_json_field_access` likewise resolves the root as a document rather
than reading the node's own type, which is what stops the fusion claiming
a chain rooted at an entry.

The declaration is left alone: it is correct for native `jsonb`, which
legitimately chains, and every encrypted case is now handled before it.

Proxy side: `JsonExtracted` reaching the encrypt path is refused rather
than defaulted to `EqlOperation::Store`. It is the result of a read, not
an operand, and storing it would encrypt the wrong payload shape and
silently return the wrong rows — the failure this term exists to prevent.
`->` and `->>` were declared to return `T`, so the result of traversing
native jsonb and the result of traversing an ENCRYPTED document had the
same type as their input. The two are not the same thing: native jsonb
yields traversable jsonb, while an encrypted document yields one SteVec
entry with no `sv` of its own, which cannot be traversed again.

`JsonLike` gains an `Output` associated type so the trait carries that
difference instead of a special case at the call site: Native resolves it
to Native, and an encrypted document resolves it to
`EqlTerm::JsonExtracted`. Plaintext chains keep working; encrypted ones
are refused wherever the chain cannot be composed into a single path.

Deliberately NOT applied to `jsonb_path_query`/`jsonb_path_query_first`.
Doing so broke two supported shapes, so it needs prior work:

  - `jsonb_array_elements` and `jsonb_array_length` CONSUME an extracted
    entry rather than traversing it, which is a legitimate operation the
    canonical array recipe depends on. They require `JsonLike`, which an
    extracted value deliberately does not satisfy.
  - the rewrite that retargets these functions to their `eql_v3` twins and
    encrypts their Path operand keys off the result type. Changing it sent
    the caller's literal jsonpath to PostgreSQL unencrypted, which
    PostgreSQL then rejected: `@ is not allowed in root expressions`.

Both point at the same missing distinction — traversing an extraction
versus consuming one — which is worth settling before the path-query
functions join this.
`->` now returns `<T as JsonLike>::Output`, so an encrypted extraction is
unqueryable — but that left the projection chain still passing, because
the fusion-aware branch intercepted before the declaration could object.
It had no way to tell `j -> 'a' -> 'b' = $1`, which fuses, from
`SELECT j -> 'a' -> 'b'`, which has no rewrite at all.

The reason is ordering. Typing is post-order, so when a chain's outermost
`->` is typed its parent does not exist yet — and the parent is precisely
what decides whether the chain is legal. `infer_enter` runs on the way
DOWN, before any child is typed, so the comparison can mark the chain
first and the `->` rule can then ask whether anything above it will
collapse the chain.

What follows from that:

  - A multi-step chain is permitted only where it is marked. Unmarked, it
    is refused with a message naming the fix, rather than emitting nested
    entry-scoped accessors over an entry and returning NULL.
  - Only EQUALITY marks. Ordering types the scalar operand but leaves the
    chain standing in the emitted SQL, so a multi-step ordering comparison
    has no correct rewrite either and is now refused. Single-field
    ordering is unaffected.
  - The whole chain SPINE is marked, one step at a time. `j->'a'->'b'->'c'`
    is three nested `BinaryOp`s and every one of them is typed, so marking
    only the outermost left the intermediates looking doomed and refused
    the query. `json_accessor_chain` jumps to the root, so this walks
    `json_accessor` instead.
  - Marks are recorded against the UNNESTED node, since that is what the
    `->` rule is handed for `((j -> 'a') -> 'b') = $1`.

A SINGLE access needs none of this: the declaration types it, so it stays
legal anywhere, projectable and decryptable as before. Only traversing an
extraction is refused.
A multi-step chain was refused everywhere except exact equality. Refusing
was the right call against the alternative it replaced — nested accessors
over an entry, answering NULL with no error — but it was a limitation,
not a necessity. The correct emission exists for every context, and it is
the same one equality already builds a path out of.

`eql_v3."->"(doc, sel)` searches the document's `sv` array and returns one
ENTRY, which has no `sv` of its own. So `j -> 'a' -> 'b'` cannot be two
hops; it is ONE accessor keyed on the composed path:

    eql_v3."->"(j, <selector for $.a.b>)

which is exactly the shape a single access already emits. Since a chain
and a single access are both typed `JsonExtracted`, they can reach exactly
the same contexts — so once a chain collapses to that shape, legality is
uniform and there is nothing left for the refusal to protect.

What that took:

  - `CollapseJsonAccessorChain` rewrites the outermost node of a chain in
    one step from its ROOT, discarding the intermediate accessors whole so
    no plaintext selector is left behind (CIP-3682). It runs BEFORE
    `RewriteContainmentOps` and emits the finished call, which makes that
    rule decline: it would otherwise key its cast decision to the type of
    the original left operand, which for a chain is the accessor being
    discarded rather than the operand that survives.

  - A parallel path-only channel, `JsonAccessorPaths`. The operand that
    survives is the outermost selector, and its own text is ONE segment of
    the path it has to key — nothing the proxy is handed at encryption
    time could recover the rest. It is a separate channel from the fused
    equality one because the two mean different encryption ops
    (`SteVecSelector` versus `SteVecValueSelector`), and a separate TYPE,
    so passing one where the other belongs does not compile.

  - The proxy composes. Where a `JsonAccessor` operand became
    `json_selector_path(val)` it now consults the record and composes every
    segment, falling back to the single-segment behaviour when there is no
    record — which is what keeps a single access on exactly its old path.
    Param steps resolve at Bind; literal steps at Parse.

The `infer_enter` mark is REPURPOSED, not removed. It no longer decides
whether a chain is legal — it decides which channel the path goes in.
Under `= $1` the accessor is absorbed into the needle and never appears,
so the path belongs to the value operand; anywhere else the accessor
survives and carries the path itself. Recording into both would be worse
than neither: the accessor channel resolves a literal operand at Parse
time, so `j -> $1 -> 'b' = $2` would start failing a query that works.

Equality keeps fusing. Its needle MACs path and value together and its
presence in the stored `sv` IS the match, which is strictly stronger than
an accessor plus a comparison — and `eql_v3.eq_term` has no overload for a
JSON query operand anyway. The collapse rule still fires on the accessor
below the comparison; the equality rule then discards that result and
re-roots the containment at the bare column, as before.

Two shapes stay refused, both because they are impossible rather than
unimplemented:

  - Split across a subquery, CTE or view. `JsonExtracted` does not carry
    the path that produced it, and the root column is not even in scope in
    the outer query, so there is nothing to root a composed path at.
    `UnqueryableJsonExtraction`, whose message now describes only this.

  - A placeholder step in front of a LITERAL final step
    (`j -> $1 -> 'b'`). The surviving operand is the literal, encrypted at
    Parse time, before `$1` is bound. Same limitation the fused equality
    has for `col -> $1 = 'value'`, and refused the same way — composing
    only what is known would key `$.b` and read a different field.

Also fixed, because the new channel made it reachable and the old one
already had it: one placeholder used as the selector of two chains with
DIFFERENT paths silently kept whichever was recorded last, answering one
occurrence from the wrong field. The path is keyed by param number
because that is all Bind has, so there is no key that could tell the two
apart — it is now an error naming the param. The same path twice is not a
conflict.

An unresolvable step (a column reference, a function call) is refused
outright rather than declined. Unlike the fused case there is no
capability check to fall through to: the chain collapses either way, so
the step would simply vanish from the statement.
…Proxy

Type-checking is not evidence here. The failure this change fixes is not
an error — it is a query that runs perfectly, decrypts nothing, and
answers NULL. So these assert the VALUE that comes back through a live
Proxy, not merely that nothing blew up.

What each one pins that the mapper tests cannot:

  - a two-level projection returns "world", the field at `$.nested.string`
  - all four spellings of that path — bare, parenthesised, `->>`, and the
    `jsonb_path_query_first` step — return the SAME value, so a walker
    that missed one cannot pass by composing a short path
  - the composed path is not a prefix of itself: `$.string` holds "hello"
    and `$.nested.string` holds "world", so dropping the inner step would
    read the wrong field and still look like a success
  - a param step resolves at Bind, with one step bound and with both
  - ordering compares the field at the path, including at the boundary
    (42 >= 42), because a chain left as two hops would compare NULL and
    read as "no rows" rather than as a failure
  - equality still matches, unchanged
  - `j -> $1 -> 'b'` never answers from a truncated path
  - a chain split across a subquery is never rewritten into a value

The document is inserted through Proxy and read back through Proxy, so
the selector each query composes has to key the same path the stored
`sv` entries were keyed on — which is the part no unit test can check.
…inert

`EqlTerm::JsonExtracted` was given no capabilities at all, on the theory
that nothing further can be done with an extracted value. That
over-reached: a SteVec entry carries ordering and equality terms, so
`ORDER BY col -> 'field'` is a legitimate, previously-working query that
sorts by `ord_term(eql_v3."->"(col, sel))` — and it started failing the
`Ord` bound.

CI caught it in the showcase, not the test suites, and the failure mode
is worth recording: with mapping errors disabled (the proxy container's
default) the refused statement was forwarded to PostgreSQL unmapped, so
native jsonb `->` ran over ciphertext and the "active Aspirin
prescriptions" query returned 0 rows with no error anywhere. The type
error existed; the flag threw it away. (Exactly the failure CIP-3680
removes.)

The entry's capabilities are now `eq` and `ord`, MASKED from the column's
own traits rather than granted outright — a JSON domain that carries no
ordering term must not gain one by extraction. The JSON capabilities stay
off: an entry has no `sv`, so traversal remains refused, which is the
distinction this type exists to draw.

Also: the showcase's ports are now environment-overridable, mirroring the
integration suite. Diagnosing this required running the showcase against
an isolated Proxy, and its hardcoded 6432 was the last thing standing in
the way — the same problem bf72e2f solved for the integration crate.
…he real limits

The showcase's CLAUDE.md declared chained `->` a critical limitation of
searchable encryption, to be lifted in a future release, and steered every
nested access through the jsonb_path_query* workaround. That release is
this branch: a chain written in one expression collapses into a single
keyed path against the root document, in every position, so the guidance
was steering examples away from syntax that now works — and would have
made Claude generate needlessly contorted showcase examples.

The section now documents what is actually true, including the limits
that remain and why each one exists rather than a bare "does not work":

  - a path split across a subquery/CTE/view is impossible, not pending —
    the extracted entry has nothing left to traverse and the root column
    is out of scope; it is rejected with a type error
  - an extracted field is orderable and equatable but not a document
  - a placeholder step followed by a literal step is refused, because the
    literal is encrypted before the placeholder is bound
  - nested jsonb_path_query* calls are not collapsed; write one path

Every claim was verified against the mapper this session, including the
multi-step ORDER BY shape, which was probed rather than assumed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant