Skip to content

Commit bf3e1ea

Browse files
authored
Merge pull request #609 from cipherstash/feat/eql-v3-supabase-adapter-follow-up
fix(stack): escape encrypted in-list operands; widen is/contains
2 parents 39e6b70 + 4b27b7a commit bf3e1ea

18 files changed

Lines changed: 2113 additions & 300 deletions
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
'@cipherstash/stack': minor
3+
---
4+
5+
Fix encrypted `in`-list operands in the Supabase adapter, and widen the `is` /
6+
`contains` type surfaces.
7+
8+
**`in()` on an encrypted column produced a request PostgREST rejects.** Every
9+
encrypted operand is a serialized envelope, dense with `"` and `,`. postgrest-js
10+
wraps a comma-bearing element as `"…"` but never escapes the quotes already
11+
inside it, so `.in('email', […])` emitted
12+
13+
```
14+
in.("{"v":1,"c":"…"}",…)
15+
^ PostgREST ends the value here → PGRST100
16+
```
17+
18+
Encrypted lists are now emitted through `filter(col, 'in', …)` with each element
19+
quoted and escaped, matching what the `.or()` path already did. This affects
20+
**v2 as well as v3** — v2's `("a@b.com")` composite literal is itself
21+
quote-bearing and was equally broken.
22+
23+
**`not(col, 'in', […])` encrypted the whole list as a single ciphertext**, so
24+
the filter silently matched nothing, and emitted an unparenthesized
25+
`not.in.a,b`. Each element is now encrypted separately and the operand is
26+
rendered as `not.in.(…)`. Passing a PostgREST list literal (`'(a,b)'`) for an
27+
encrypted column now throws instead of silently matching nothing — pass an
28+
array.
29+
30+
**`is(col, null)` is now allowed on every column**, including storage-only
31+
encrypted ones (`types.Boolean`, `types.Integer`, …). `is` is never encrypted
32+
and a NULL plaintext is stored as a SQL NULL, so `IS NULL` is not merely legal
33+
there but the only predicate those columns support. `is(col, true)` remains a
34+
compile error on encrypted columns.
35+
36+
**`contains()` accepts native operands on plaintext array and jsonb columns.** A
37+
plaintext jsonb/array column falls through to PostgREST's native containment, so
38+
`contains('tags', ['vip'])` and `contains('meta', { plan: 'pro' })` now
39+
typecheck. A plaintext SCALAR column does not: `@>` is undefined on `text`, so
40+
the operand type follows the column's own shape and a scalar rejects every
41+
containment operand. Encrypted match columns still take a `string` token.
42+
Relatedly, `.or([{ op: 'contains' }])` now emits PostgREST's `cs` operator for
43+
plaintext columns too — previously only encrypted conditions were translated, so
44+
a plaintext containment reached the wire as `.contains.` and failed to parse.
45+
46+
**Direct `contains()` / `not(col, 'contains', …)` now serialize their operand.**
47+
postgrest-js builds an array operand as `cs.{a,b}` with no element quoting, so
48+
`contains('tags', ['with,comma'])` reached Postgres as two elements; and its
49+
`not()` stringifies the operand outright, emitting `not.contains.with,comma`
50+
(no braces, and the wrong operator token) or `[object Object]` for a jsonb
51+
operand. Both paths now build the containment literal the `.or()` path already
52+
built, and emit the `cs` token.
53+
54+
**`.or()` no longer drops a condition after an unbalanced brace or paren.** A
55+
scalar operand containing `{` left the parser's depth counter stranded above
56+
zero, so no later comma separated a condition and everything behind it was
57+
swallowed into that operand. With a plaintext column first, the group was then
58+
forwarded verbatim — running the swallowed condition against a ciphertext column
59+
with a plaintext operand. Braces are now quoted on emit (they are structural to
60+
PostgREST inside `or=(…)`), and the parser falls back to quote-only splitting
61+
when its depth tracking does not balance.
62+
63+
**`is(col, true)` is now rejected on every encrypted column, not just the
64+
storage-only ones.** The boolean form was gated on the filterable keys, which
65+
exclude storage-only columns but keep queryable encrypted ones — so
66+
`is(emailTextSearchColumn, true)` compiled and emitted `IS TRUE` against a jsonb
67+
ciphertext.
68+
69+
**In-list operands encrypt in one crossing per column.** The element-wise `in` /
70+
`not.in` encoding above spent one ZeroKMS round-trip per element; terms are now
71+
grouped by column and each group takes a single `bulkEncrypt` call, matching the
72+
Drizzle v3 path. Falls back to per-term encryption for clients without
73+
`bulkEncrypt`, and rejects a bulk response whose length does not match the list
74+
rather than silently truncating the predicate.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@cipherstash/stack': patch
3+
---
4+
5+
Fix the Supabase adapter's `.or()` string parser mis-splitting conditions, and pin `contains()` on a mixed union column key to the encrypted operand.
6+
7+
An `.or()` string is only rebuilt from its parse when it references an encrypted column — otherwise the caller's string is forwarded verbatim — so each of these corrupts precisely the mixed encrypted/plaintext case.
8+
9+
**Quotes were tracked only at brace depth 0.** A `}` inside a quoted array element or jsonb string value closed the literal early, and the next `"` re-opened quoting, so the following top-level comma never split: `.or('tags.cs.{"a}b"},email.eq.secret')` parsed as a single condition and silently absorbed `email.eq.secret` into the operand. Quotes are now opaque at every depth.
10+
11+
**A stray `}` or `)` drove the depth counter negative**, after which no comma split again. `}` and `)` are not PostgREST reserved characters, so `a}b` is a valid unquoted operand and `.or('nickname.eq.a}b,id.eq.1')` dropped `id.eq.1`. Depth now floors at zero.
12+
13+
**`in`-list elements were split on every comma, ignoring quotes.** `.or('email.in.("a,b",c)')` parsed as three elements with the quotes still embedded; on an encrypted column each fragment was encrypted as its own term, so the intended element never matched. Elements are now split on top-level commas and unquoted, the inverse of what the rebuild emits.
14+
15+
**A parenthesized operand was read as a list for every operator.** Only `in` and the range operators (`ov`, `sl`, `sr`, `nxr`, `nxl`, `adj`) take a paren-delimited operand; elsewhere `(` is an ordinary character. `email.eq.(foo)` parsed as `['foo']` and encrypted a JS array rather than the string, matching nothing.
16+
17+
**A string operand spelling `null`, `true` or `false` is now quoted.** PostgREST reads a bare `null` as SQL NULL, so `.or([{ column: 'name', op: 'eq', value: 'null' }])` emitted `name.eq.null` and compared against NULL instead of the three-character string.
18+
19+
**`contains(col, …)` where `col` is a union spanning an encrypted and a plaintext column** accepted an array or object operand. The union is now only as permissive as its strictest member: any declared encrypted column in the union pins the operand to `string`. A literal column argument was never affected.

packages/stack/__tests__/eql-v3-domain-registry.test.ts

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,61 @@ import {
88
type V3ColumnFactory,
99
} from '@/eql/v3/domain-registry'
1010

11+
/**
12+
* The EXTERNAL CONTRACT: the SQL domain names this package ships as the
13+
* `information_schema` query parameter (`introspect.ts`) and matches
14+
* `domain_name` rows against.
15+
*
16+
* Hand-written ON PURPOSE. `DOMAIN_REGISTRY` is derived from
17+
* `stripDomainSchema(factory(…).getEqlType())`, so any expectation *also*
18+
* derived from `getEqlType()` only measures the source against itself: corrupt
19+
* a domain constant in `columns.ts` and the registry silently re-keys, the SQL
20+
* param ships the wrong name, real columns are misclassified as unmodelled —
21+
* and a derived assertion still passes. This literal list is the only thing
22+
* that fails. Do not compute it.
23+
*/
24+
const EXPECTED_DOMAIN_KEYS = [
25+
'integer',
26+
'integer_eq',
27+
'integer_ord_ore',
28+
'integer_ord',
29+
'smallint',
30+
'smallint_eq',
31+
'smallint_ord_ore',
32+
'smallint_ord',
33+
'bigint',
34+
'bigint_eq',
35+
'bigint_ord_ore',
36+
'bigint_ord',
37+
'date',
38+
'date_eq',
39+
'date_ord_ore',
40+
'date_ord',
41+
'timestamp',
42+
'timestamp_eq',
43+
'timestamp_ord_ore',
44+
'timestamp_ord',
45+
'numeric',
46+
'numeric_eq',
47+
'numeric_ord_ore',
48+
'numeric_ord',
49+
'text',
50+
'text_eq',
51+
'text_match',
52+
'text_ord_ore',
53+
'text_ord',
54+
'text_search',
55+
'boolean',
56+
'real',
57+
'real_eq',
58+
'real_ord_ore',
59+
'real_ord',
60+
'double',
61+
'double_eq',
62+
'double_ord_ore',
63+
'double_ord',
64+
] as const
65+
1166
describe('DOMAIN_REGISTRY', () => {
1267
it('strips the public. schema prefix', () => {
1368
expect(stripDomainSchema('public.text_search')).toBe('text_search')
@@ -16,26 +71,25 @@ describe('DOMAIN_REGISTRY', () => {
1671
expect(stripDomainSchema('boolean')).toBe('boolean')
1772
})
1873

19-
it('has an entry for every types factory, keyed by the unqualified domain', () => {
20-
const factories = Object.values(types) as V3ColumnFactory[]
21-
for (const factory of factories) {
22-
const eqlType = factory('probe').getEqlType()
23-
const key = stripDomainSchema(eqlType)
24-
expect(
25-
DOMAIN_REGISTRY[key],
26-
`missing registry entry for ${key}`,
27-
).toBeDefined()
28-
expect(DOMAIN_REGISTRY[key]('c').getEqlType()).toBe(eqlType)
29-
}
74+
it('keys are exactly the expected SQL domain names', () => {
75+
expect(Object.keys(DOMAIN_REGISTRY).sort()).toEqual(
76+
[...EXPECTED_DOMAIN_KEYS].sort(),
77+
)
3078
})
3179

32-
it('has no registry entry that does not round-trip to its own key', () => {
33-
for (const [key, factory] of Object.entries(DOMAIN_REGISTRY)) {
34-
expect(stripDomainSchema(factory('c').getEqlType())).toBe(key)
80+
it('maps each expected domain to a factory that builds that domain', () => {
81+
for (const key of EXPECTED_DOMAIN_KEYS) {
82+
const factory = factoryForDomain(key)
83+
expect(factory, `missing registry entry for ${key}`).toBeDefined()
84+
expect((factory as V3ColumnFactory)('c').getEqlType()).toBe(
85+
`public.${key}`,
86+
)
3587
}
3688
})
3789

38-
it('has exactly as many entries as there are types factories', () => {
90+
// The derivation drops an entry rather than throwing only if two factories
91+
// collide on one key; a short registry is that collision.
92+
it('derives one entry per types factory, with no key collisions', () => {
3993
expect(Object.keys(DOMAIN_REGISTRY)).toHaveLength(Object.keys(types).length)
4094
})
4195

@@ -44,18 +98,8 @@ describe('DOMAIN_REGISTRY', () => {
4498
expect(factoryForDomain('text_search')).toBe(DOMAIN_REGISTRY.text_search)
4599
})
46100

47-
it('PROPERTY: round-trips for any registry key and rejects any non-key', () => {
48-
const keys = Object.keys(DOMAIN_REGISTRY)
49-
// Any known key builds a column whose stripped eqlType is that key.
50-
fc.assert(
51-
fc.property(fc.constantFrom(...keys), (key) => {
52-
expect(stripDomainSchema(DOMAIN_REGISTRY[key]('c').getEqlType())).toBe(
53-
key,
54-
)
55-
}),
56-
)
57-
// Any arbitrary string that is not a registry key resolves to undefined.
58-
const keySet = new Set(keys)
101+
it('PROPERTY: rejects any string that is not a registry key', () => {
102+
const keySet = new Set(Object.keys(DOMAIN_REGISTRY))
59103
fc.assert(
60104
fc.property(fc.string(), (s) => {
61105
fc.pre(!keySet.has(s))
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* A test double that can see the WIRE FORMAT.
3+
*
4+
* `createMockSupabase` records the arguments handed to each builder method and
5+
* stops there. That pins per-element *encryption* but is structurally blind to
6+
* *encoding*: postgrest-js still has to serialize those arguments into a query
7+
* string, and that is where an unescaped `"` inside an encrypted envelope turns
8+
* `in.(…)` into a request PostgREST rejects. A mock that never builds a URL can
9+
* never catch it.
10+
*
11+
* So this harness runs the REAL `PostgrestClient` with a `fetch` that captures
12+
* the request URL and answers with canned rows. No database, no `DATABASE_URL`
13+
* gate — but the operand is byte-for-byte what a live PostgREST would receive.
14+
*
15+
* Use it for anything whose correctness lives in the emitted query string
16+
* (`in`, `not.in`, `or`); keep using `createMockSupabase` for everything else.
17+
*/
18+
19+
import { PostgrestClient } from '@supabase/postgrest-js'
20+
import type { SupabaseQueryBuilder } from '@/supabase/types'
21+
22+
export type WirePostgrest = {
23+
/** Structurally a supabase client: `.from(table)` → a query builder. */
24+
client: { from(table: string): SupabaseQueryBuilder }
25+
/** Every request URL issued, in order. */
26+
urls: string[]
27+
/** The decoded operand for `column` on the last request, e.g. `in.("…","…")`. */
28+
operandFor(column: string): string
29+
}
30+
31+
export function createWirePostgrest(resultData: unknown = []): WirePostgrest {
32+
const urls: string[] = []
33+
34+
const fetchImpl = (input: unknown): Promise<Response> => {
35+
urls.push(
36+
typeof input === 'string'
37+
? input
38+
: String((input as { url: string }).url ?? input),
39+
)
40+
return Promise.resolve(
41+
new Response(JSON.stringify(resultData), {
42+
status: 200,
43+
headers: { 'Content-Type': 'application/json' },
44+
}),
45+
)
46+
}
47+
48+
const client = new PostgrestClient('http://wire.test', {
49+
fetch: fetchImpl as unknown as typeof fetch,
50+
})
51+
52+
return {
53+
client: client as unknown as WirePostgrest['client'],
54+
urls,
55+
operandFor(column: string): string {
56+
const last = urls.at(-1)
57+
if (last === undefined) throw new Error('no request was issued')
58+
// `searchParams.get` percent-decodes; PostgREST sees exactly this.
59+
const value = new URL(last).searchParams.get(column)
60+
if (value === null) {
61+
throw new Error(
62+
`no filter emitted for column "${column}" in ${new URL(last).search}`,
63+
)
64+
}
65+
return value
66+
},
67+
}
68+
}

packages/stack/__tests__/helpers/supabase-mock.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ export function createMockEncryptionClient() {
8383
encrypt: (value: unknown, opts: { column: { getName(): string } }) =>
8484
operation(fakeEnvelope(value, opts.column.getName())),
8585

86+
// v3 filter path: one FFI crossing per (table, column) group. Position-
87+
// stable and envelope-identical to `encrypt`, so every wire assertion holds
88+
// whichever path the builder takes.
89+
bulkEncrypt: (
90+
payloads: Array<{ plaintext: unknown }>,
91+
opts: { column: { getName(): string } },
92+
) =>
93+
operation(
94+
payloads.map((p) => ({
95+
data: fakeEnvelope(p.plaintext, opts.column.getName()),
96+
})),
97+
),
98+
8699
// v2 filter path: batch query terms as composite literals
87100
encryptQuery: (terms: Array<{ value: unknown }>) =>
88101
operation(terms.map((t) => `("${String(t.value)}")`)),

0 commit comments

Comments
 (0)