Repository navigation
fix(2.2): refuse type-check fallbacks that would forward plaintext - #493
Conversation
With mapping errors off, a statement that fails type checking falls back: Proxy forwards the client's Query unchanged. That also forwarded every other statement in the Query as written, so an INSERT into an encrypted column beside a statement Proxy could not type check reached PostgreSQL with its plaintext values, whichever order the statements came in. The fallback is now refused, with the type-check error, when: - another statement in the simple Query needs mapping (encryption or decryption), or - the statement that fails type checking writes data while encrypted columns are configured, in the simple or extended protocol, as Proxy cannot tell which columns it writes to. A read that fails type checking is still forwarded when nothing beside it needs mapping. Ports eql_mapper::writes_data from main to find writes, including ones in PREPARE, EXPLAIN and data-modifying CTEs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The integration test passthrough_insert_from_select failed: 2.2's type checker does not support ANY over an array, so an INSERT ... SELECT into the plaintext table was refused as a write that fails type checking. A write that fails type checking cannot put plaintext in an encrypted column when Proxy knows every table it writes to has no encrypted columns, so it falls back again. It is still refused when a target is encrypted, unknown, has no known columns, or is not a plain table name (as with MERGE). eql_mapper::write_targets names the tables a statement writes to, and the table resolution the COPY check uses is shared as Frontend::known_tables. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…on suite The write now fails type checking with encrypted columns configured, so Proxy refuses it instead of forwarding it to PostgreSQL. Matches the Rust integration test and the same test on main. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
freshtonic
left a comment
There was a problem hiding this comment.
Reviewed head b8a5990f against release/2.2 (4938a420). Approved. This fixes the fail-open from my review of #491 (CIP-5168): both repros are now refused, and a fallback with nothing to map is still forwarded unchanged. One test is missing (S1). I will add it in a follow-up PR on top of this branch. The other items are notes and nits, and they can go in this PR or later.
Test gap (I will add the test in a follow-up PR)
S1. No test covers a target table with no known columns. writes_encrypted refuses a write to a table that Proxy knows but whose columns it does not know (table.columns.is_empty(), for example a table created with AS on this connection). When I removed that condition, all 11 type_check_fallback tests still passed. The follow-up adds CREATE TABLE staging AS SELECT * FROM users, and then an INSERT and an UPDATE on staging that fail the type check. Both must be refused.
Notes (not blocking)
-
A plaintext predicate on an encrypted column still reaches PostgreSQL in a fallback. I confirmed these with a scratch test. Both are forwarded unchanged, and no error goes to the client:
SELECT id FROM users WHERE email = 'a@example.com' AND id = ANY(ARRAY[1::int8])UPDATE notes SET body = 'x' FROM users WHERE users.email = 'a@example.com' AND notes.id = ANY(ARRAY[1::int8])
The first is the read fallback, which
mainalso keeps (may_fall_backallows a read that hits a coverage gap). The PR description says this is intended. The second is a result of the exception for plaintext targets, whichmaindoes not have: onmain, a write never falls back while encrypted columns are configured. The UPDATE leaks no more than a SELECT with the same predicate, so I accept the exception, because 2.2's type checker misses common plaintext writes. But the CHANGELOG anddocs/errors.mdmust not let a reader think that a fallback never sends plaintext. Maybe add one sentence: "A statement that falls back is sent as written, so a plaintext value that it compares with an encrypted column reaches the database." -
mainalso refuses a nested write in a statement that it does not type check (requires_type_checkis false andwrites_datais true). On 2.2,check_copycovers the usual case,COPY (INSERT …) TO STDOUT. Statements such asCREATE RULE … DO INSERT …still go through without a check. This is rare, so I note it only for parity withmain.
Nits
writes_encryptedalso returnstruefor a write to an unknown table, or to a target that it cannot name. A name such asmay_write_encryptedagrees better with the doc comment.- The comment "PostgreSQL stops at the failed statement, so a
CREATEafter it is not run" is older than this PR, but it is now on the new loop. It is true only when PostgreSQL also rejects the statement. When the failure is only in Proxy's type check (the production case in the PR description), PostgreSQL runs the statement and theCREATEafter it, and Proxy does not record the schema change. The new checks are safe here, because a write to a table that Proxy does not know is refused. But the comment is wrong for this case.
What I checked and found correct
-
Mutations. I removed each item below, and for each one at least one
type_check_fallbacktest failed:|| mappedin the first branch.- The refusal when a later statement needs mapping.
- The refusal of a later write that fails the type check.
- The
writes_encryptedcheck in the first branch. - The
writes_encryptedcheck in the Parse handler. known_tablesreturningNone→ refuse.write_targetsreturningNone→ refuse.
The one condition that did not cause a failure is in S1.
-
mappedcovers decryption.to_encryptable_statementreturnsSomefor an encrypted projection, so aSELECT email FROM usersbeside a failing statement is refused in either order. The testa_read_of_an_encrypted_column_beside…checks this. -
known_tables. It returns both the table created on this connection and the loaded table, so a name that also has an encrypted table in another schema is refused. The COPY check uses the same resolution, without a change in behaviour. -
writes_dataandwrite_targets. They find writes inPREPARE,EXPLAINand data-modifying CTEs.MERGE, a multi-tableDELETE, anUPDATEwith joins and anINSERTinto a table function returnNone, so they are refused. -
Mapping disabled. A statement after
SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = trueis skipped in the new loop, andis_passthrough()also checks the global setting. The two ways of disabling mapping behave as before. -
The Python test.
encrypted_column_not_defined_in_schemawrites to a known encrypted table, so Proxy's refusal now comes before PostgreSQL's error. The new expectation is correct.
What I ran
cargo test -p cipherstash-proxy --lib: 281 of 282 passed. The one failure,config::tandem::tests::prometheus_config, is the environment race. Theconfig::tests with--test-threads=1: 42 of 42 passed.cargo test -p eql-mapper --lib: 79 passed, 6 ignored.- The mutations and the scratch test above. I reverted all changes after the runs.
- CI: all 4 checks pass.
|
Follow-up: #495 adds the missing test from S1 in my review (a fallback write to a table with no known columns is refused). It is based on this branch, and GitHub changes its base to |
|
Thanks for the review. I'm about to push one fast-forward commit on top of
S1 is left to your follow-up as you proposed. The Generated by Claude Code |
Review follow-ups: rename writes_encrypted to may_write_encrypted, since it is also true for an unknown target, and correct the comment on the fallback loop, as PostgreSQL runs a CREATE after a statement that only Proxy failed to type check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Requested by Toby
With mapping errors off (the default), a statement that fails type checking falls back: Proxy forwards the client's Query unchanged.
That also forwarded every other statement in the Query as the client wrote it.
So
SELECT * FROM no_such_table; INSERT INTO users (email) VALUES ('a@example.com')reached PostgreSQL with the plaintext email, and so did the reverse order (where Proxy encrypted the INSERT and then discarded the result).In production the failing statement can be one PostgreSQL runs but Proxy cannot type check, such as a read of a table Proxy has not loaded, so the INSERT runs with plaintext in the SQL.
This was already on the 2.2 base before #481, so it is not a regression from the recent backports.
This mirrors main's batch rule ("a fallback forwards the whole Query as the client wrote it, so it must not carry a statement that needs mapping") and main's write policy.
Changes
SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = trueis still forwarded as written, as before.INSERT/UPDATE/DELETE/MERGE, including ones inPREPARE,EXPLAINand data-modifying CTEs. The exception is a write where Proxy knows every target table has no encrypted columns (2.2's type checker misses common plaintext writes, such asid = ANY(ARRAY[...])). A target that is encrypted, unknown, has no known columns, or is not a plain table name (MERGE) is refused.eql_mapper::write_targetsnames the targets, and the COPY check's table resolution is shared asFrontend::known_tables.eql_mapper::writes_datais ported from main, with its unit test.encrypted_column_not_defined_in_schemanow expects Proxy's type-check error instead of PostgreSQL's "column does not exist", as main does.docs/errors.mdand theCHANGELOG.md[Unreleased]section describe the new behaviour.Tests
New
postgresql::tests::type_check_fallbackmodule. The two reported repros (encrypted INSERT after and before a failing statement), a read of an encrypted column beside a failing statement, and the write cases (simple and extended) failed before the fix, each forwarding the plaintext or the Query unchanged. They pass now, along with cases that must still fall back: a lone failing read, a failing read beside plaintext statements orSET/DISCARD, a write after mapping is disabled, and a write with no encrypted columns.cargo test -p cipherstash-proxy -p eql-mapper --liband clippy with-D warningspass locally. Integration tests run in CI.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