Repository navigation
fix(proxy): refuse writes to a table Proxy cannot resolve - #486
Conversation
|
Review of commit The pinned change improves unresolved-write handling but enables schema-qualified encryption with column metadata that still collides across schemas. Independent impact: High. The change affects the SQL mapping and refusal boundary for all connections with encrypted columns, including simple-query batches, prepared statements, and schema-qualified access; incorrect decisions can affect persisted data and searchable-encryption behavior. [P1] Preserve schema identity when selecting encryption configuration — packages/cipherstash-proxy/src/proxy/schema/manager.rs:405 Qualified names can now resolve separate tables with the same name, but their encryption configurations still share Carry the resolved schema through encryption-config lookup, or refuse collisions with incompatible configurations before allowing these newly resolved statements to map. |
|
Agreed on the P1. Fixed in 025dba5 by refusing the collision, not by carrying the schema through. Carrying the schema through the encrypt-config lookup would change Instead, Tests: One related gap remains and is not fixed here. Inside a transaction, a The same push also carries 21f623d, which fixes the red Test jobs: Generated by Claude Code |
|
Pushed 53e51a6 as a fast-forward on top of 025dba5, with no history rewrite. It closes a fail-open freshtonic found on #484 that also applies here. If every encrypted column loses its encrypt config to a domain conflict, the config is empty, so the unresolvable-table refusal turned off. Encrypted columns are now also counted from the loaded schema, and a regression test covers the empty-config case. Generated by Claude Code |
freshtonic
left a comment
There was a problem hiding this comment.
I reviewed 025dba5b and then the new commit 53e51a64; the current head is 53e51a64. I approve on one condition: this PR merges together with #484, as its description says. If it merged alone, it would not be less secure than main, but the known gaps listed in the description would stay open.
Fixed in 53e51a64
In the #484 review, I found B2: the write refusal turned off when every encrypted column lost its config to a domain conflict. This PR has the same gate: frontend.rs:1293 uses has_encrypted_columns(), so before the fix #486 forwarded the bare INSERT INTO users (email) VALUES ('plaintext') as written. 53e51a64 counts the EQL columns of the loaded schema as encrypted columns, which closes this case here. write_is_refused_when_only_columns_with_no_config_are_encrypted covers it. With this PR alone, UPDATE … FROM (VALUES …) and MERGE are still forwarded. Those are the known gaps that #484 closes.
Should fix
Two tables with the same name and the same domain share one config and one ZeroKMS descriptor.
In manager.rs:446 (the Some(_) => {} branch), the key is Identifier("users", "email"), so app.users.email and public.users.email use the same ColumnConfig and the same descriptor, users/email. The keyset is per connection, so the two tables also share keys. Encryption is correct, and no write uses the wrong config. But a ciphertext copied from one table to the other still decrypts, and equal plaintexts give the same search terms in both tables. So the two tables are not cryptographically separate. This is a design property, not a regression: on main, writes to either table fell back to plaintext. Please document it in docs/errors.md and in the CHANGELOG, next to the sentence about different domains, or create a follow-up ticket to key the config by schema.
Nits
select_table_schemas.sql:36still says "Consumers keep the first and ignore later duplicates". Lines 22-28 still say that the schema map uses the bare name as its key. This PR changes both rules.- The SQL loads only
BASE TABLErows. So with encryption configured, writes to updatable views and foreign tables are now refused. The compatibility note in the CHANGELOG names only schemas outside the search path and bare names in two schemas. Please add views and foreign tables. native_tables(middleware.rs:166,:304) is never cleared on DROP or ROLLBACK. So afterCREATE TEMP TABLE t; DROP TABLE t, anINSERT INTO tthat does not resolve still counts as a write to a native table. This matters only until the next schema reload after another connection creates an encryptedt, and main forwards that case too. I found this by reading the code. #484 replaces this set with its temporary-table layer.
What I checked and found correct
- Identifier case. Unquoted names fold to lowercase and quoted names stay as written. A mismatch fails closed. For example,
APP.usersmatches the schemaapp, but"APP".usersdoes not. - Names that do not resolve. A three-part name, or a write qualified with
pg_catalogor with a schema outside the search path, is refused. A read frompg_catalogis forwarded. - A bare name in two schemas does not resolve, so a write through it is refused.
search_pathchanges that Proxy cannot see. The value is stored as ciphertext, never as plaintext. Main behaves the same way.- Overlay. Qualified names are looked up as the client spelled them.
- Multi-statement fallback. The fallback and its look-ahead work with the statements in either order, and refusals propagate.
- Extended protocol. The Parse handler refuses these writes too.
- The Go pgx temporary-table failure is fixed.
- Your earlier P1 (the wrong-domain config) is fixed for different domains.
- Reads. When the domains differ,
SELECT email FROM app.usersis refused. A bareSELECT email FROM usersreturns ciphertext, as on main. A native column with the same name never gets the config. - No regression against main. Every case that this PR forwards, main forwards too.
What I ran
- On
025dba5b: eql-mapper 220 passed and cipherstash-proxy 440 passed, with no failures. - 10 mutations, and each one made at least one test fail. One of them (removing the
has_encrypted_columnsgate) was caught only by a test that keeps the gate in place. - The probe for domain conflicts with an empty config, on #486 and on #484, before
53e51a64.
|
Thanks @freshtonic. Pushing a docs-only fast-forward now with no rewrite. It documents that same-domain tables share a config and descriptor (in docs/errors.md and the CHANGELOG), adds views and foreign tables to the compatibility note, and corrects the comments in Generated by Claude Code |
|
Pushing a comment-only fast-forward now, with no rewrite. It removes issue-tracker references from the code comments this PR added. No behaviour changes. Generated by Claude Code |
|
"Supported pooler compatibility (PostgreSQL 17)" failed on e0fb5da, which changes only comments. The same job passed on 2ca6d25. The probe got the expected MappingCrossClient result, but then PgCat returned Generated by Claude Code |
|
Heads up: at Toby's request I'm force-pushing a rewritten history. The eight commits become three signed ones, rebased onto the latest main (3304f28):
The final tree is the old head e0fb5da plus main's two new commits, and nothing else changed. #484 will be rewritten on top of it. Generated by Claude Code |
e0fb5da to
6143e4e
Compare
A `Table` now records the schema it was loaded from, and `Schema::resolve_table` resolves `schema.table` only to a table loaded from that schema. A bare name resolves only when exactly one schema has it. The overlay keeps the schema across transactions. Adds the helpers Proxy needs to fail closed on writes to a table the mapper cannot resolve: `as_table_not_found` on each error layer, `writes_data` (any INSERT, UPDATE, DELETE or MERGE, including nested ones) and `write_targets` (the tables a statement writes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The schema loader now records every table's schema, so a qualified name resolves to that schema's table. Duplicates are kept. A bare name found in two search-path schemas stays ambiguous, because a client can change its search_path without Proxy seeing it. Encrypt config is still keyed by the bare table name. When same-named tables give a column different EQL domains, that column gets no config and a warning is logged, rather than one table's config encrypting another table's column. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When mapping errors are off, Proxy forwards a statement it cannot type check as written. A table missing from the loaded schema fails type checking, so a write to it reached PostgreSQL with its values in plaintext, even when the table had encrypted columns. While any column is encrypted, a write whose table cannot be resolved is now refused with `MappingError::UnresolvableTable`, whatever the mapping-errors setting. Encrypted columns are counted from the loaded schema as well as the encrypt config, so a column whose config was dropped over a domain conflict still counts. Reads keep the fallback. A write is still forwarded when every table it writes is a native table that Proxy does not model: a temporary table, or one created earlier in the same batch. In a simple query with several statements, the fallback applies only when no other statement needs mapping. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6143e4e to
e9d509a
Compare
Requested by Toby
Fixes a fail-open: a write to a table Proxy can't resolve was forwarded with its values unencrypted.
When Proxy can't type check a statement and mapping errors are off, it forwards the statement as written.
A table missing from the loaded schema fails type checking, so an
INSERT/UPDATE/DELETE/MERGEon it reached PostgreSQL in plaintext.Changes
MappingError::UnresolvableTable, whateverenable_mapping_errorssays (must_fail_closed). Read-only statements keep the fallback.SELECT … FROM some_view; INSERT INTO encrypted …sent the INSERT's plaintext.Tablerecords its database schema (Table::in_schema), andpublic.usersresolves only to a table loaded frompublic. The overlay looks qualified names up by the caller's spelling.docs/errors.md#mapping-unresolvable-table, CHANGELOG (Security and Fixed), and the "type-check fallback" as a fourth passthrough condition inpackages/cipherstash-proxy/CONTEXT.md.Known gaps, addressed in #484
#484 is stacked on this branch and replaces the frontend pieces with one refuse-by-default route per statement. Merge this together with #484, not on its own:
MERGEand SQLPREPARE … AS INSERT(always unsupported) still fall back.INSERT INTO encrypted … SELECT … FROM temp_tfalls back. refactor(proxy): route each statement through one refuse-by-default decision #484 checks write targets instead.Testing
unresolvable_table_testsinfrontend.rs(simple and extended protocol, mapping errors on, no encrypt config, multi-statement fallback, schema-qualified encryption, temporary tables), schema-qualified resolution andwrites_datain eql-mapper,build_snapshotduplicate names inmanager.rs.cargo test -p cipherstash-proxy -p eql-mapperpasses, andcargo clippy --workspace --all-targets -D warningsis clean.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