Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/eql-v3-drizzle-fail-open-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@cipherstash/stack': minor
---

Close two fail-open paths in the EQL v3 Drizzle adapter.

`ops.contains()` now throws `EncryptionOperatorError` for a search term that
tokenizes to nothing: the empty string, or a term shorter than the match index
tokenizer's `token_length` (3 by default). Such a term produces an empty bloom
filter, and `stored_bf @> '{}'` is true for every row — so a user searching
`"ad"` silently received the entire table. Measured live, the terms `"ad"`,
`"a"` and `"x"` each returned every seeded row, including one in which `"x"`
did not appear.

The floor counts Unicode codepoints, matching the tokenizer. A UTF-16 length
check would wave through an astral-plane term — `"👍👍"` is 4 code units but
only 2 codepoints, yields no trigram, and matched every row.

**Breaking for callers passing short terms:** `contains()` calls that previously
returned every row now throw. Terms of 3+ codepoints are unaffected.

`v3FromDriver()` now throws the new `EqlV3CodecError` on a payload that is not
an EQL envelope, instead of surfacing a raw `SyntaxError` for malformed JSON and
passing a bare scalar through unchecked — `v3FromDriver('5')` previously returned
`5` typed as `Encrypted`, which then reached `decrypt` as garbage. The guard
accepts both scalar envelopes (ciphertext at `c`) and SteVec documents
(ciphertext at `sv[0].c`). A SteVec's `sv` must be a non-empty array: `sv[0]` is
the decryption root, so `sv: []` carries a ciphertext key but no ciphertext, and
is now rejected rather than passed to `decrypt`. `EqlV3CodecError` is exported
from `@cipherstash/stack/eql/v3/drizzle` so callers can catch it.

Also removes an unreachable branch in `inArray`/`notInArray`, whose empty-list
guard already throws before it.

Note: the v2 Drizzle adapter's `like`/`ilike` path builds the same bloom filters
and has the same short-term fail-open. It is **not** fixed here — v2 terms carry
SQL wildcards, so the floor must be measured against what its tokenizer actually
receives before the shared guard can be reused. Tracked separately.
130 changes: 121 additions & 9 deletions packages/stack/__tests__/drizzle-v3/codec.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,77 @@
import { describe, expect, it } from 'vitest'
import { v3FromDriver, v3ToDriver } from '@/eql/v3/drizzle/codec'
import {
EqlV3CodecError,
v3FromDriver,
v3ToDriver,
} from '@/eql/v3/drizzle/codec'

// A realistic `public.text_eq` envelope: schema version, table/column
// identifier, mp_base85 ciphertext, HMAC term. The trivial `{v:1,c:'ct'}`
// fixture this replaced could not distinguish an envelope from a bare object.
const ENVELOPE = {
v: 3,
i: { t: 'users', c: 'email' },
c: 'mBbKmbNmNhX@Vv0N%<Ke7fH(=Zc*d4rDy0h3~yPZLq!kGm8x7$2Fw<TnR>1jQvA',
hm: '3a1f9c0e5b7d2a48e6c1f0b93d7a2e58c4b6f19d0e3a7c25b8f14d69a0c3e7b2',
} as const

// SteVec documents carry the root ciphertext at `sv[0].c` and have no top-level
// `c` — the envelope guard must accept them.
const STE_VEC_ENVELOPE = {
v: 3,
k: 'sv',
i: { t: 'users', c: 'profile' },
sv: [{ c: 'mBbKciphertext', s: 'selector', b: 'term' }],
} as const

describe('v3 codec', () => {
it('serialises an object to a jsonb string', () => {
expect(v3ToDriver({ v: 1, c: 'ct' })).toBe('{"v":1,"c":"ct"}')
it('serialises an envelope to a jsonb string', () => {
expect(v3ToDriver(ENVELOPE as never)).toBe(JSON.stringify(ENVELOPE))
})

it('maps null/undefined to SQL NULL (JS null), never the JSON null literal', () => {
expect(v3ToDriver(null)).toBeNull()
expect(v3ToDriver(undefined)).toBeNull()
})

it('parses a jsonb string back to an object', () => {
expect(v3FromDriver('{"v":1,"c":"ct"}')).toEqual({ v: 1, c: 'ct' })
it('round-trips a realistic envelope through both directions', () => {
const serialised = v3ToDriver(ENVELOPE as never)
expect(v3FromDriver(serialised)).toEqual(ENVELOPE)
})

it('round-trips unicode and a long ciphertext', () => {
const envelope = {
...ENVELOPE,
i: { t: 'users', c: 'café_☕' },
c: 'z'.repeat(8192),
}
expect(v3FromDriver(v3ToDriver(envelope as never))).toEqual(envelope)
})

it('passes an already-parsed envelope through unchanged', () => {
expect(v3FromDriver(ENVELOPE as never)).toBe(ENVELOPE)
})

it('accepts a SteVec document, whose ciphertext lives at sv[0].c', () => {
expect(v3FromDriver(STE_VEC_ENVELOPE as never)).toBe(STE_VEC_ENVELOPE)
expect(v3FromDriver(JSON.stringify(STE_VEC_ENVELOPE))).toEqual(
STE_VEC_ENVELOPE,
)
})

// `sv: []` has no `sv[0]`, so the guard's premise — a ciphertext at the
// decryption root — does not hold. Presence of the key is not enough.
it.each([
['an empty SteVec', { v: 3, k: 'sv', sv: [] }],
['a non-array SteVec', { v: 3, k: 'sv', sv: {} }],
['a null SteVec with no c', { v: 3, k: 'sv', sv: null }],
])('rejects %s', (_label, envelope) => {
expect(() => v3FromDriver(envelope as never)).toThrow(EqlV3CodecError)
expect(() => v3FromDriver(envelope as never)).toThrow(/ciphertext/)
})

it('passes an already-parsed object through unchanged', () => {
const obj = { v: 1 }
expect(v3FromDriver(obj)).toBe(obj)
it('still accepts a scalar envelope, which has c and no sv', () => {
expect(v3FromDriver(ENVELOPE as never)).toBe(ENVELOPE)
})

it('normalises null/undefined to SQL NULL (JS null) on read', () => {
Expand All @@ -26,6 +80,64 @@ describe('v3 codec', () => {
})

it('does not throw on a stray bigint in the envelope (defensive)', () => {
expect(v3ToDriver({ v: 1n } as never)).toBe('{"v":"1"}')
expect(v3ToDriver({ ...ENVELOPE, v: 3n } as never)).toContain('"v":"3"')
})

// Before these, a malformed string surfaced a raw SyntaxError and a
// wrong-shape payload was returned as-is — `v3FromDriver('5')` yielded `5`
// typed as an envelope, which then reached `decrypt` as garbage.
describe('malformed and non-envelope payloads', () => {
it.each([
'{bad',
'',
'{"v":3,',
])('throws EqlV3CodecError on unparseable jsonb %j', (raw) => {
expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError)
expect(() => v3FromDriver(raw)).toThrow(/Failed to parse/)
})

// The parse failure is the only codec path with an upstream error to
// preserve. Flattening it to a message string discards the SyntaxError and
// its stack, so a caller debugging a corrupt column loses where parsing
// actually broke.
it.each([
'{bad',
'',
'{"v":3,',
])('preserves the underlying SyntaxError as `cause` for %j', (raw) => {
let thrown: unknown
try {
v3FromDriver(raw)
} catch (error) {
thrown = error
}

expect(thrown).toBeInstanceOf(EqlV3CodecError)
expect((thrown as EqlV3CodecError).cause).toBeInstanceOf(SyntaxError)
})

it.each([
'5',
'"a string"',
'true',
'[]',
'[{"v":3,"c":"ct"}]',
])('throws on valid JSON %j that is not an envelope', (raw) => {
expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError)
})

it('throws on an object missing the schema version', () => {
expect(() => v3FromDriver({ c: 'ct' })).toThrow(/missing "v"/)
})

it('throws on an object carrying no ciphertext', () => {
expect(() => v3FromDriver({ v: 3, i: { t: 'u', c: 'e' } })).toThrow(
/missing a ciphertext/,
)
})

it('throws on an array', () => {
expect(() => v3FromDriver([ENVELOPE] as never)).toThrow(/got an array/)
})
})
})
41 changes: 41 additions & 0 deletions packages/stack/__tests__/drizzle-v3/column.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,45 @@ describe('makeEqlV3Column', () => {
expect(pgColumn?.getSQLType()).toBe(eqlType)
expect(getEqlV3Column(columnName, pgColumn)?.getEqlType()).toBe(eqlType)
})

// The `toDriver`/`fromDriver` closures wired into `customType` (column.ts) are
// otherwise only tested via the standalone codec functions, so the wiring
// itself — including the SQL-NULL safety net — was never exercised.
describe('codec wiring on the built column', () => {
type Mapper = {
mapToDriverValue(value: unknown): unknown
mapFromDriverValue(value: unknown): unknown
}
const mapperFor = (name: string): Mapper => {
const table = pgTable('users', {
[name]: makeEqlV3Column(v3Types.TextEq(name)),
} as never)
return (table as unknown as Record<string, Mapper>)[name]
}

const ENVELOPE = {
v: 3,
i: { t: 'users', c: 'nickname' },
c: 'mBbKciphertext',
hm: 'hmac',
}

it('serialises an envelope on write and parses it back on read', () => {
const column = mapperFor('nickname')
const driverValue = column.mapToDriverValue(ENVELOPE)

expect(driverValue).toBe(JSON.stringify(ENVELOPE))
expect(column.mapFromDriverValue(driverValue)).toEqual(ENVELOPE)
})

it('maps a null envelope to SQL NULL on write', () => {
expect(mapperFor('nickname').mapToDriverValue(null)).toBeNull()
})

it('rejects a non-envelope driver value rather than passing it through', () => {
expect(() => mapperFor('nickname').mapFromDriverValue('5')).toThrow(
/EQL encrypted envelope/,
)
})
})
})
36 changes: 32 additions & 4 deletions packages/stack/__tests__/drizzle-v3/exports.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
import { describe, expect, it } from 'vitest'
import * as barrel from '@/eql/v3/drizzle'

// Exhaustive, so ADDING an export fails here too. The previous version asserted
// only four names, leaving the codec/column re-exports unpinned and letting a
// new export slip into the public surface unnoticed.
const PUBLIC_SURFACE = [
'EncryptionOperatorError',
'EqlV3CodecError',
'createEncryptionOperatorsV3',
'extractEncryptionSchemaV3',
'getEqlV3Column',
'isEqlV3Column',
'makeEqlV3Column',
'types',
'v3FromDriver',
'v3ToDriver',
] as const

describe('v3 drizzle barrel', () => {
it('exports the public surface', () => {
expect(typeof barrel.createEncryptionOperatorsV3).toBe('function')
expect(typeof barrel.extractEncryptionSchemaV3).toBe('function')
expect(typeof barrel.EncryptionOperatorError).toBe('function')
it('exports exactly the public surface', () => {
expect(Object.keys(barrel).sort()).toEqual([...PUBLIC_SURFACE])
})

// The key check above sees names, not values: a re-export that resolves to
// `undefined` still lists its key. Sweep every export the barrel claims.
it('exports every name as a callable, and types as a namespace', () => {
const callables = Object.entries(barrel).filter(
([name]) => name !== 'types',
)
expect(callables).toHaveLength(PUBLIC_SURFACE.length - 1)

for (const [name, value] of callables) {
expect(typeof value, name).toBe('function')
}

expect(typeof barrel.types.TextSearch).toBe('function')
expect(barrel.types.IntegerOrd('age')).toBeDefined()
})
Expand Down
Loading
Loading