Repository navigation
Conversation
A statement prepared with SQL PREPARE while UNSAFE_DISABLE_MAPPING is on is never mapped. Once mapping is enabled again, EXECUTE of it is forwarded with its arguments as written, so only PostgreSQL's domain check stops plaintext reaching an encrypted column. The Bind settings check covers only protocol-level statements. These tests fail on main: Proxy forwards the EXECUTE instead of refusing it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Proxy now records each SQL PREPARE that writes data and is forwarded while UNSAFE_DISABLE_MAPPING is on. Once mapping is enabled again, EXECUTE of one is refused with PreparedSettingsChanged, as Bind is for a statement parsed with the extended protocol. The name is released when PostgreSQL confirms a DEALLOCATE or DISCARD ALL that removes it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…search path The loader reads only the schemas in its connection's current_schemas. When every encrypted column is in a schema outside them, Proxy sees no encrypted columns, so a write to a table it cannot resolve falls back and is forwarded as written. The test runs the loader as a role whose search path leaves out the schema holding an encrypted column. It fails on main. SchemaManager is exported so the integration tests can run it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Name the statements a SQL PREPARE and EXECUTE refer to with to_writing_preparation and to_execution, beside to_deallocation, so route reads as one check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The schema query now reads every non-system schema and reports whether each table's schema is on the loader's search path. Only those tables are loaded, as before. Encrypted columns in the others set Schema::encrypted_columns_elsewhere, so has_encrypted_columns is true and a write to a table Proxy cannot resolve fails closed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tobyhede
left a comment
There was a problem hiding this comment.
Review of commit 2cd3cff3979918bc9a696901861459efabacc713. The PR has advanced since this review; newer commits have not been assessed.
The reviewed fix covers standalone preparation and deallocation, but leaves a correctness gap for multi-statement queries, detailed inline. PostgreSQL domain validation remains a fallback; this finding does not establish that plaintext can be stored. Tests were not executed.
| match &deallocation { | ||
| Deallocation::Statement(name) => { | ||
| state.unmapped_sql_statements.remove(name); | ||
| } | ||
| Deallocation::AllStatements => state.unmapped_sql_statements.clear(), |
There was a problem hiding this comment.
[P1] Preserve unmapped preparations that follow deallocation in the same query
query_handler routes every statement before forwarding the query, so a later SQL PREPARE enters unmapped_sql_statements before PostgreSQL acknowledges an earlier DEALLOCATE. This unconditional removal then deletes the later preparation's protection.
For example, send each of these as a separate simple-query message:
SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true;DEALLOCATE ALL; PREPARE p AS INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2);SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = false;EXECUTE p(123, '{"plaintext":"hello"}');Routing the second message records p, its DEALLOCATE ALL completion clears the set, and the subsequent PREPARE completion never restores it. EXECUTE therefore passes the new guard and forwards its arguments unchanged. Named deallocation followed by re-preparation has the same issue.
Please track preparation/deallocation effects in execution order, or preserve preparations occurring after the acknowledged deallocation, and add a regression test for this sequence. PostgreSQL domain validation remains a fallback; this is not evidence that plaintext is successfully stored.
There was a problem hiding this comment.
Confirmed, thanks: reproduced as sql_execute_of_a_statement_prepared_after_a_deallocation_in_the_same_query_is_refused (both DEALLOCATE ALL and DEALLOCATE p), which failed before the fix.
Fixed in the next push (not yet pushed): each unmapped preparation records the operation that ran it, and a confirmed deallocation removes only names recorded by an earlier operation, the same rule PreparedStatements::deallocate uses. The extended protocol had the same gap with a pipelined DEALLOCATE executed before a PREPARE portal, so that PREPARE is now recorded again at Execute (sql_execute_of_a_statement_prepared_after_a_pipelined_deallocation_is_refused). The trade-off is fail-closed: PREPARE p ...; DEALLOCATE p in one Query keeps p recorded.
Generated by Claude Code
Routing records a SQL PREPARE before PostgreSQL runs the statements of the Query, so a DEALLOCATE earlier in the same Query, confirmed later, removed the new preparation's record and let EXECUTE through. Each unmapped preparation now records the operation that ran it, and a confirmed deallocation removes only names recorded by an earlier operation. A PREPARE parsed with the extended protocol is recorded again when its portal is executed, so a pipelined deallocation executed before it does not remove it either. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # packages/cipherstash-proxy-integration/src/disable_mapping.rs
cipherstash-agent
left a comment
There was a problem hiding this comment.
Review attestation: /code-review medium on head ea46d88, cross-checked with company-skills:cross-check-review-findings against the existing PR feedback. The existing feedback (one thread, the same-query DEALLOCATE ordering) was not repeated; that fix is covered by the new tests.
Blocking: none. Critical: none.
Findings (1 new, major):
EXPLAIN ANALYZE EXECUTE p(...)bypasses the newPreparedSettingsChangedguard and is forwarded with plaintext arguments (inline,frontend.rs). PostgreSQL's domain check remains the fallback.
The other changes (search-path schema loading, encrypted_columns_elsewhere, deallocation ordering) verified without findings.
Generated by Claude Code
| return Ok(Route::Forward(Forward::MappingDisabled)); | ||
| } | ||
| // Like the Bind settings check, for a statement prepared with SQL. | ||
| if let Some(name) = to_execution(statement) { |
There was a problem hiding this comment.
major to_execution matches only a top-level Statement::Execute, so EXPLAIN ANALYZE EXECUTE p('plaintext') skips this guard: eql_mapper has no rule for Execute, the EXPLAIN type-checks to NothingEncrypted, and it is forwarded unchanged (reproduced at ea46d88 with a scratch test after PREPARE, then mapping off then on). EXPLAIN ANALYZE runs the insert, so it is the same bypass. Unwrap Statement::Explain (and any other wrapper) before calling to_execution.
Generated by Claude Code
There was a problem hiding this comment.
Confirmed: reproduced by extending sql_execute_of_a_statement_prepared_without_mapping_is_refused with EXPLAIN ANALYZE EXECUTE p('plaintext'), which failed before the fix.
Fixed in the next push (not yet pushed): to_execution now looks through Statement::Explain, recursively, so any EXPLAIN of an EXECUTE of an unmapped statement is refused. A plain EXPLAIN without ANALYZE is refused too, which is fail-closed. No other statement can wrap an EXECUTE that runs a write: PREPARE cannot prepare an EXECUTE, and CREATE TABLE AS EXECUTE needs a query.
Generated by Claude Code
EXPLAIN ANALYZE runs the statement it explains, so EXPLAIN ANALYZE EXECUTE of a statement prepared while UNSAFE_DISABLE_MAPPING was on wrote its arguments as written. to_execution now looks through EXPLAIN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Requested by Toby
Draft. Two paths that could forward plaintext to an encrypted column, found by tracing the Quint routing model against the code. Each was shown failing in CI before its fix.
1. SQL
EXECUTEof a statement prepared without mappingThe bug
With
SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true, a SQLPREPARE p AS INSERT INTO encrypted (...) VALUES ($1, $2)is forwarded, since mapping is disabled.After
SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = false,EXECUTE p(...)was forwarded with its arguments as written:requires_type_checkexcludesStatement::Execute, andwrites_datafinds no write inside anEXECUTE, soroutereturnedForward::NotTypeChecked.PreparedSettingsChanged) covers only protocol-level Parse/Bind, not SQLPREPARE/EXECUTE.So PostgreSQL's domain check on the EQL column was the only thing between the plaintext and the encrypted column.
Red first
The first commit (
519e1a4) adds only the tests. Its CI run failed on exactly one test, the new unit test, on all four PostgreSQL versions: Proxy forwardedEXECUTE p('plaintext')unchanged.CI stops at the unit stage, so the integration test did not run against the unfixed Proxy; on
mainits assertion fails because the refusal comes from PostgreSQL, not Proxy.The fix
routerecords each SQLPREPAREthat writes data and is forwarded while mapping is disabled.EXECUTEof a recorded name is refused withPreparedSettingsChanged, as Bind is.DEALLOCATEorDISCARD ALLthat removes it (the existingcomplete_deallocationpath). A laterPREPAREof the same name does not release it, since PostgreSQL may have rejected thatPREPAREand kept the old statement.to_writing_preparationandto_executionname the statements involved, besideto_deallocation;sql_statement_namefolds identifiers as PostgreSQL does.Not covered here: a writing
PREPAREforwarded while no encrypted columns were configured, then executed after the column becomes encrypted. ThatEXECUTEis still forwarded (the fidelity review's "stale" path).Tests
frontend.rs:sql_execute_of_a_statement_prepared_without_mapping_is_refusedandsql_execute_after_deallocating_a_statement_prepared_without_mapping_is_forwarded(unit).disable_mapping.rs:sql_execute_of_a_statement_prepared_without_mapping_is_refused(integration). Expects Proxy's refusal rather than PostgreSQL's domain error, and no row written.2. Encrypted columns off the loader's search path
The bug
The schema query was filtered to
current_schemas(false)of the loader's connection.When every encrypted table sat in a schema off that path, Proxy saw no encrypted columns at all, so a write to a table it could not resolve fell back and was forwarded as written instead of refused.
Red first
0f48c26addsschema_loader::encrypted_columns_off_the_loaders_search_path_are_counted(integration). It creates a role whose search path leaves out a schema holding aneql_v3_text_searchcolumn, runsSchemaManageras that role, and expects encrypted columns to be counted. It failed on PostgreSQL 16 locally (and passed with the hidden schema added to the search path, as a control), and the CI run on that commit failed.SchemaManageris now exported fromproxyso the integration tests can run it.The fix
on_search_pathper table.Schema::encrypted_columns_elsewhere, whichhas_encrypted_columnsincludes, so those writes fail closed.table_off_the_search_path_is_not_loaded_but_its_encrypted_columns_count.Quint model
frontend.rsis in theproxy-quintcorrespondence table, so the model needs a change: SQLPREPARE/EXECUTEis a value-carrying statement that A3 currently assumes away.After this fix, model SQL
PREPAREunder unsafe mode as recording an unmapped slot and SQLEXECUTEof that slot as refused while mapping is on, mirroring the Bind check. For R2, the model'sencConfiguredalready counts unresolvable columns, so it now matches the code instead of over-stating it; A2's comment can drop the "not found" caveat.The model changes belong with the Quint model PR, not here.
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.
Generated by Claude Code