Repository navigation
fix(stack,supabase,prisma): keep plaintext search terms out of errors and logs #1554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
43967d3
fix(stack,supabase,prisma): keep plaintext search terms out of errors…
claude ba272d9
fix(stack,supabase): log a failure's kind, not its message, in Dynamo…
claude c0ee563
fix(stack,supabase,prisma): allowlist logged failure codes, sanitize …
claude 1eb3135
docs(changeset): limit the privacy claim to the changed errors and logs
claude d61bfd4
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude b29eaa1
test(stack-supabase): assert the ciphertext error log carries the fai…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
99 changes: 99 additions & 0 deletions
99
languages/typescript/packages/stack-supabase/__tests__/no-plaintext-in-errors.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
89 changes: 89 additions & 0 deletions
89
languages/typescript/packages/stack/__tests__/describe-failure.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minor Every library validation
Errorthat reaches this catch now logs only(Error), because none of them carries atypeorcodefordescribeFailureto report. For example,.order('email')on aTextEqcolumn 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 stablecode(such asORDER_UNSUPPORTEDorNEEDLE_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
There was a problem hiding this comment.
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
Errornow logs as(Error). Fixing it would mean giving every Supabase validation throw site a code and adding those codes to thedescribeFailureallowlist. That is a cross-adapter error-taxonomy change, larger than this fix. The caller still gets the full message in the returnederror.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