Skip to content

fix(proxy): close two plaintext paths found by the Quint routing review - #492

Open
tobyhede wants to merge 8 commits into
mainfrom
claude/project-thread-rdt6z3
Open

tobyhede wants to merge 8 commits into
mainfrom
claude/project-thread-rdt6z3

Conversation

@tobyhede

@tobyhede tobyhede commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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 EXECUTE of a statement prepared without mapping

The bug

With SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true, a SQL PREPARE 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_check excludes Statement::Execute, and writes_data finds no write inside an EXECUTE, so route returned Forward::NotTypeChecked.
  • The Bind settings check (PreparedSettingsChanged) covers only protocol-level Parse/Bind, not SQL PREPARE/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 forwarded EXECUTE p('plaintext') unchanged.
CI stops at the unit stage, so the integration test did not run against the unfixed Proxy; on main its assertion fails because the refusal comes from PostgreSQL, not Proxy.

The fix

  • route records each SQL PREPARE that writes data and is forwarded while mapping is disabled.
  • While mapping is enabled and encrypted columns are configured, EXECUTE of a recorded name is refused with PreparedSettingsChanged, as Bind is.
  • A name is released only when PostgreSQL confirms a DEALLOCATE or DISCARD ALL that removes it (the existing complete_deallocation path). A later PREPARE of the same name does not release it, since PostgreSQL may have rejected that PREPARE and kept the old statement.
  • to_writing_preparation and to_execution name the statements involved, beside to_deallocation; sql_statement_name folds identifiers as PostgreSQL does.

Not covered here: a writing PREPARE forwarded while no encrypted columns were configured, then executed after the column becomes encrypted. That EXECUTE is still forwarded (the fidelity review's "stale" path).

Tests

  • frontend.rs: sql_execute_of_a_statement_prepared_without_mapping_is_refused and sql_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

0f48c26 adds schema_loader::encrypted_columns_off_the_loaders_search_path_are_counted (integration). It creates a role whose search path leaves out a schema holding an eql_v3_text_search column, runs SchemaManager as 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. SchemaManager is now exported from proxy so the integration tests can run it.

The fix

  • The schema query reads every non-system schema and returns on_search_path per table.
  • Only tables on the search path are loaded, as before, so bare-name resolution and the encrypt config are unchanged.
  • An encrypted column in any other table sets Schema::encrypted_columns_elsewhere, which has_encrypted_columns includes, so those writes fail closed.
  • Unit test: table_off_the_search_path_is_not_loaded_but_its_encrypted_columns_count.

Quint model

frontend.rs is in the proxy-quint correspondence table, so the model needs a change: SQL PREPARE/EXECUTE is a value-carrying statement that A3 currently assumes away.
After this fix, model SQL PREPARE under unsafe mode as recording an unmapped slot and SQL EXECUTE of that slot as refused while mapping is on, mirroring the Bind check. For R2, the model's encConfigured already 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

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>
@tobyhede tobyhede self-assigned this Oct 26, 2026
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 tobyhede changed the title fix(proxy): refuse SQL EXECUTE of a statement prepared without mapping fix(proxy): close two plaintext paths found by the Quint routing review Oct 26, 2026

@tobyhede tobyhede left a comment

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.

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.

Comment on lines +919 to +923
match &deallocation {
Deallocation::Statement(name) => {
state.unmapped_sql_statements.remove(name);
}
Deallocation::AllStatements => state.unmapped_sql_statements.clear(),

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.

[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.

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.

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
@tobyhede
tobyhede marked this pull request as ready for review October 26, 2026 22:31

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 new PreparedSettingsChanged guard 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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.

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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants