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
10 changes: 10 additions & 0 deletions .changeset/no-plaintext-search-terms-in-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@cipherstash/stack': patch
'@cipherstash/stack-supabase': patch
'@cipherstash/stack-prisma': patch
'stash': patch
---

Two validation errors no longer quote the caller's search term: a free-text search term that is too short, and a `like`/`ilike` pattern with wildcards the encrypted search cannot honour. Each now reports the term's length instead.

The Supabase and DynamoDB adapters' error logs now record a failure's kind instead of its full message, which could quote a plaintext value. The kind is the error class plus any known encryption error type, FFI error code or auth code it carries; anything else is left out. The DynamoDB `options.logger` hook now receives a copy of the error whose message is that description, with the same `code`. The full message is still returned to the caller and passed to `errorHandler`. `@cipherstash/stack/adapter-kit` exports the shared `describeFailure` and `likeWildcardError` helpers that produce these messages.
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@

import {
jsonPathOf,
likeWildcardError,
matchNeedleError,
parseSelectorSegments,
unsupportedLeafReason,
Expand Down Expand Up @@ -537,7 +538,7 @@ function normalizeMatchNeedle(
throw operatorError(
ctx,
method,
`cipherstash ${method}: pattern ${JSON.stringify(value)} on column "${ctx.columnName ?? 'unknown'}" has wildcards fuzzy free-text matching cannot honor (an interior "%" or any "_"). Pass a literal search term.`,
`cipherstash ${method} on column "${ctx.columnName ?? 'unknown'}": ${likeWildcardError(value)} Pass a literal search term.`,
)
}
const match = ctx.meta.indexes.match
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ describe('cipherstash v3 operator lowering — eqlMatch needle guards', () => {
}
})

// The pattern is the caller's plaintext search term. The error is thrown to
// the caller, who commonly logs it, so it reports the term's length only.
it('never echoes the rejected pattern in the wildcard error', () => {
for (const needle of ['Qzx%wvy', 'Qzx_wvy']) {
let message = ''
try {
lowerMatch(needle)
} catch (err) {
message = (err as Error).message
}
expect(message, needle).toMatch(/cannot honor/)
expect(message, needle).not.toContain('Qzx')
expect(message, needle).not.toContain('wvy')
}
})

it('rejects a needle that is empty after stripping with the matchNeedleError reason', () => {
expect(() => lowerMatch('%%')).toThrow(EncryptionOperatorError)
expect(() => lowerMatch('%%')).toThrow(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { logger } from '@cipherstash/stack/adapter-kit'
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EncryptedQueryBuilderImpl } from '../src/query-builder'
import {
createMockEncryptionClient,
createMockSupabase,
} from './helpers/supabase-mock'

// A search term on an encrypted column is plaintext the caller chose to
// protect. The library promises never to log plaintext, and its own error
// messages are routinely logged by callers, so neither may carry the term.
// These tests use distinctive sentinels so a substring check cannot pass by
// accident against an ordinary English word in the message.

const users = encryptedTable('users', {
email: types.TextSearch('email'),
nickname: types.TextEq('nickname'),
})
const COLUMNS = ['id', 'email', 'nickname']

function builder(encryptionClient = createMockEncryptionClient()) {
const supabase = createMockSupabase([])
return new EncryptedQueryBuilderImpl(
'users',
users,
encryptionClient,
supabase.client,
COLUMNS,
)
}

/** Every string the logger was handed, across all levels. */
function logged(spies: ReturnType<typeof vi.spyOn>[]): string {
return spies
.flatMap((spy) => spy.mock.calls.flat())
.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg)))
.join('\n')
}

function spyLogger() {
return (['error', 'warn', 'info', 'debug'] as const).map((level) =>
vi.spyOn(logger, level).mockImplementation(() => {}),
)
}

afterEach(() => {
vi.restoreAllMocks()
})

describe('plaintext search terms stay out of errors and logs', () => {
it('a too-short matches() needle is neither logged nor echoed in the error', async () => {
const spies = spyLogger()

const { error } = await builder().select('id').matches('email', 'Qz')

expect(error?.message).toMatch(/at least 3 characters/)
expect(error?.message).not.toContain('Qz')
expect(logged(spies)).not.toContain('Qz')
})

it('a like() pattern with unsupported wildcards is not echoed in the error', () => {
for (const pattern of ['Qzx%wvy', 'Qzx_wvy']) {
let message = ''
try {
builder().select('id').like('email', pattern)
} catch (err) {
message = (err as Error).message
}
expect(message, pattern).toMatch(/cannot honor/)
expect(message, pattern).not.toContain('Qzx')
expect(message, pattern).not.toContain('wvy')
}
})

// The catch around query execution sees errors from every layer (encryption,
// PostgREST, the network), and any of them may quote a value. It reports the
// failure's kind; the message itself goes back to the caller, not to the log.
it('a failed query logs the failure kind, not the error message', async () => {
const encryption = createMockEncryptionClient()
vi.spyOn(
encryption as unknown as { bulkEncrypt: (...a: unknown[]) => unknown },
'bulkEncrypt',
).mockImplementation(() => {
throw new TypeError('value "Qzx-secret-wvy" could not be encoded')
})
const spies = spyLogger()

const { error } = await builder(encryption)
.select('id')
.in('nickname', ['Qzx-secret-wvy', 'other'])

expect(error?.message).toContain('Qzx-secret-wvy')
const output = logged(spies)
expect(output).toMatch(/failed on table "users"/)
expect(output).toContain('TypeError')
expect(output).not.toContain('Qzx')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,11 @@ describe('plaintext table rows that carry ciphertext', () => {

expect(error?.message).toContain('"[0].meta"')
expect(error?.message).not.toContain('ssn-078-05-1120')
// The message reaches the logger too, so the log must not carry it either.
// The log names the failure's kind, never the message (CIP-5161), so it
// carries neither the key nor the row path.
expect(logged).toHaveBeenCalled()
for (const args of logged.mock.calls) {
expect(args.join(' ')).toContain('"[0].meta"')
expect(args.join(' ')).toContain('DecryptionError')
expect(args.join(' ')).not.toContain('ssn-078-05-1120')
}
} finally {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { AuditConfig } from '@cipherstash/stack/adapter-kit'
import {
describeFailure,
likeWildcardError,
logger,
parseSelectorSegments,
reconstructSelectorDocument,
Expand Down Expand Up @@ -597,8 +599,12 @@ export class EncryptedQueryBuilderImpl<
} satisfies DecryptContext)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// Log the failure's kind, never its message: this catch sees errors from
// every layer (query-term validation, encryption, PostgREST), and their
// messages may quote a plaintext value. The full message still reaches
// the caller in the returned `error`.
logger.error(
`Supabase encrypted query failed on table "${this.tableName}": ${message}`,
`Supabase encrypted query failed on table "${this.tableName}" (${describeFailure(err)}).`,
)
Comment on lines 606 to 608

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor Every library validation Error that reaches this catch now logs only (Error), because none of them carries a type or code for describeFailure to report. For example, .order('email') on a TextEq column throws at :816, and the log no longer names the column or the reason, even though that message never held plaintext. Give the adapter's validation errors a stable code (such as ORDER_UNSUPPORTED or NEEDLE_TOO_SHORT) or a named class, so the log keeps the failure kind and still leaves out the message.

Source: code-review (medium) · claude-opus-5-5 · verified by review-cross-check


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaving this as is in this PR. The finding is accurate: a plain validation Error now logs as (Error). Fixing it would mean giving every Supabase validation throw site a code and adding those codes to the describeFailure allowlist. That is a cross-adapter error-taxonomy change, larger than this fix. The caller still gets the full message in the returned error.message, so nothing is lost to the person running the query. Only the library's own log is coarser.

I'll raise it as a follow-up.


Generated by Claude Code


// A failure inside any of the encrypt/decrypt steps above is thrown as an
Expand Down Expand Up @@ -893,7 +899,7 @@ export class EncryptedQueryBuilderImpl<
const { needle, hasUnsupportedWildcard } = parseLikeNeedle(pattern)
if (hasUnsupportedWildcard) {
throw new Error(
`[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an unescaped internal "%" or any unescaped "_"). Use matches("${column}", term) with a literal search term.`,
`[supabase v3]: "${op}" on encrypted column "${column}": ${likeWildcardError(pattern)} Escape a literal "%" or "_" as "\\%" or "\\_", or use matches("${column}", term) with a literal search term.`,
)
}
const key = `${op}:${column}`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { PROTECT_ERROR_CODES } from '@cipherstash/protect-ffi'
import { describe, expect, it } from 'vitest'
import {
describeFailure,
FFI_ERROR_CODES,
} from '@/utils/logger/describe-failure'

// `describeFailure` is what adapters log in place of an error's message, which
// may quote a plaintext value (CIP-5161). Everything it returns must come from
// a fixed allowlist, never from the error, however identifier-like the error's
// fields look.
describe('describeFailure', () => {
it('names an Error by its class, never its message', () => {
expect(describeFailure(new TypeError('value "Qzx" is bad'))).toBe(
'TypeError',
)
})

it('appends known type, code and authCode identifiers', () => {
const err = Object.assign(new Error('boom Qzx'), {
type: 'EncryptionError',
code: 'UNKNOWN_COLUMN',
authCode: 'USAGE_LIMIT_EXCEEDED',
})
expect(describeFailure(err)).toBe(
'Error: EncryptionError: UNKNOWN_COLUMN: USAGE_LIMIT_EXCEEDED',
)
})

it('reads identifiers from a nested encryptionError', () => {
const err = Object.assign(new Error('Qzx'), {
name: 'EncryptionFailedError',
encryptionError: {
type: 'EncryptionError',
message: 'Qzx',
authCode: 'USAGE_LIMIT_EXCEEDED',
},
})
expect(describeFailure(err)).toBe(
'EncryptionFailedError: EncryptionError: USAGE_LIMIT_EXCEEDED',
)
})

// An identifier-shaped field can still hold caller data, so the shape is not
// the test: only known values pass.
it('drops identifier-shaped values that are not known codes', () => {
const err = Object.assign(new Error('invalid value'), {
code: 'Qzx-secret-wvy',
type: 'alice.smith',
authCode: '123-45-6789',
})
expect(describeFailure(err)).toBe('Error')
expect(describeFailure({ code: 'Qzx_secret', message: 'x' })).toBe('object')
expect(describeFailure({ code: 42883 })).toBe('object')
})

it('reports an unknown error class as Error', () => {
const renamed = new Error('x')
renamed.name = 'QzxSecret'
expect(describeFailure(renamed)).toBe('Error')
})

it('describes a non-object by its type', () => {
expect(describeFailure('Qzx')).toBe('string')
expect(describeFailure(null)).toBe('null')
expect(describeFailure(undefined)).toBe('undefined')
})

// Called from inside a catch: if describing the failure threw, Supabase would
// reject instead of returning `{ error }`, and DynamoDB would skip its
// errorHandler.
it('returns a fixed description when reading the error throws', () => {
const err = new Error('x')
Object.defineProperty(err, 'type', {
get() {
throw new Error('getter Qzx')
},
})
expect(describeFailure(err)).toBe('unknown failure')

const { proxy, revoke } = Proxy.revocable({}, {})
revoke()
expect(describeFailure(proxy)).toBe('unknown failure')
})

it('allowlists exactly the FFI error codes protect-ffi declares', () => {
expect([...FFI_ERROR_CODES].sort()).toEqual([...PROTECT_ERROR_CODES].sort())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,55 @@ describe('handleError', () => {
expect(rethrow('ECONNRESET').code).toBe('DYNAMODB_ENCRYPTION_ERROR')
})

// The message may quote a plaintext value (CIP-5161). It is returned to the
// caller on the error, but the library's own log names only the failure kind.
it('logs the failure kind and code, never the message', () => {
const failure = Object.assign(
new Error('value "Qzx-secret-wvy" rejected'),
{
code: 'UNKNOWN_COLUMN',
},
)

const error = handleError(failure, 'encryptModel')

expect(error.message).toContain('Qzx-secret-wvy')
const logged = errorLog.mock.calls.flat().join('\n')
expect(logged).toContain('encryptModel')
expect(logged).toContain('UNKNOWN_COLUMN')
expect(logged).not.toContain('Qzx')
})

// `options.logger` is a log sink the caller wires to `console` or a log
// service, so it gets the same log-safe description as the shared logger.
// The full message stays on the returned error and on `errorHandler`'s.
it('hands the configured logger a log-safe error, and errorHandler the full one', () => {
const failure = Object.assign(
new Error('value "Qzx-secret-wvy" rejected'),
{
code: 'UNKNOWN_COLUMN',
},
)
const logged: unknown[] = []
const handled: Error[] = []

const error = handleError(failure, 'encryptModel', {
logger: { error: (message, err) => logged.push(message, err) },
errorHandler: (err) => handled.push(err),
})

expect(error.message).toContain('Qzx-secret-wvy')
expect(handled[0]?.message).toContain('Qzx-secret-wvy')
const [message, loggedError] = logged as [string, Error & { code: string }]
expect(message).toContain('encryptModel')
expect(loggedError).toBeInstanceOf(Error)
expect(loggedError.code).toBe('UNKNOWN_COLUMN')
expect(
[message, loggedError.message, String(loggedError.stack)].join('\n'),
).not.toContain('Qzx')
expect(loggedError.message).toContain('UNKNOWN_COLUMN')
})

it('extracts the message from an Error, a plain object, or anything else', () => {
expect(
handleError(new Error('from an Error'), 'decryptModel').message,
Expand All @@ -463,7 +512,7 @@ describe('handleError', () => {
expect(handleError(null, 'decryptModel').message).toBe('null')
})

it('hands the constructed error to both the errorHandler and the caller logger', () => {
it('hands the constructed error to the errorHandler and a log-safe copy to the caller logger', () => {
const seen: unknown[] = []
const callerLog = { error: vi.fn() }

Expand All @@ -478,7 +527,16 @@ describe('handleError', () => {
// classified one.
expect(seen).toHaveLength(1)
expect(seen[0]).toBe(error)
expect(callerLog.error).toHaveBeenCalledWith('Error in decryptModel', error)
// The logger gets a copy with the same code but a log-safe message
// (CIP-5161), not the caller's error.
expect(callerLog.error).toHaveBeenCalledWith(
'Error in decryptModel',
expect.objectContaining({
code: error.code,
message: expect.stringContaining('DynamoDB error in decryptModel'),
}),
)
expect(callerLog.error.mock.calls[0][1]).not.toBe(error)
expect(errorLog).toHaveBeenCalledWith(
expect.stringContaining('DynamoDB error in decryptModel'),
)
Expand Down
Loading
Loading