Skip to content

fix(proxy): refuse writes to a table Proxy cannot resolve - #486

Merged
tobyhede merged 3 commits into
mainfrom
claude/project-thread-2yjn9s
Oct 23, 2026
Merged

tobyhede merged 3 commits into
mainfrom
claude/project-thread-2yjn9s

Conversation

@tobyhede

@tobyhede tobyhede commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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/MERGE on it reached PostgreSQL in plaintext.

Changes

  • Refuse writes to an unresolvable table. With encrypted columns configured, a write whose type check fails with a missing table is refused with MappingError::UnresolvableTable, whatever enable_mapping_errors says (must_fail_closed). Read-only statements keep the fallback.
  • Multi-statement Query. The type-check fallback forwards the whole Query as written, so it now applies only when no other statement in the Query needs mapping or is refused. Previously SELECT … FROM some_view; INSERT INTO encrypted … sent the INSERT's plaintext.
  • Schema-qualified names (eql-mapper). Table records its database schema (Table::in_schema), and public.users resolves only to a table loaded from public. The overlay looks qualified names up by the caller's spelling.
  • Same name in two schemas. The loader records each table's schema. A bare name used in two search-path schemas stays unresolved, so a write through it is refused, because a client can change its search path without Proxy seeing it. A qualified name resolves.
  • Native temporary tables. The schema middleware doesn't model them, so writes to them looked unresolvable (this broke the Go pgx test). The middleware now records the native temporary tables a connection creates, when the statement is sent, and the refusal skips them.
  • Docs: docs/errors.md#mapping-unresolvable-table, CHANGELOG (Security and Fixed), and the "type-check fallback" as a fourth passthrough condition in packages/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:

  • The refusal keys off the first type-check error, so an earlier error (for example an unsupported CTE) can hide a missing table, and MERGE and SQL PREPARE … AS INSERT (always unsupported) still fall back.
  • The temporary-table skip keys off the missing table's name, so INSERT INTO encrypted … SELECT … FROM temp_t falls back. refactor(proxy): route each statement through one refuse-by-default decision #484 checks write targets instead.

Testing

  • Unit tests: unresolvable_table_tests in frontend.rs (simple and extended protocol, mapping errors on, no encrypt config, multi-statement fallback, schema-qualified encryption, temporary tables), schema-qualified resolution and writes_data in eql-mapper, build_snapshot duplicate names in manager.rs.
  • cargo test -p cipherstash-proxy -p eql-mapper passes, and cargo clippy --workspace --all-targets -D warnings is clean.
  • Integration tests run in CI only.

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

@tobyhede tobyhede self-assigned this Oct 22, 2026
@tobyhede
tobyhede requested review from auxesis and freshtonic and removed request for freshtonic October 22, 2026 23:50

Copy link
Copy Markdown
Contributor Author

Review of commit 5ab96c8e3040a8e7b6aebe67de4f13bc85460a42:

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 Identifier::new(table_name, column_name): build_snapshot retains only the first configuration, and ColumnMapper::get_column selects that configuration without the resolved schema or domain. For example, with public.users.email declared eql_v3_text and app.users.email declared eql_v3_text_search, a qualified INSERT into app.users now passes type checking but encrypts using the storage-only configuration from public.users, omitting the search terms required by the target. Different token types likewise select the wrong cast configuration.

Carry the resolved schema through encryption-config lookup, or refuse collisions with incompatible configurations before allowing these newly resolved statements to map.

Copy link
Copy Markdown
Contributor Author

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 Identifier, the table.column pair that also names the ZeroKMS descriptor. That's a much larger change than this PR should make.

Instead, build_snapshot keeps a column's configuration only while every same-named table declares it with the same EQL domain. When the domains differ, it removes the configuration and logs a warning. The mapper still sees the column as encrypted, so ColumnMapper finds no config and the statement is refused with UnknownColumn, on bare and qualified names alike. When the domains match, the configuration is the same for both tables, so it's kept.

Tests: same_named_encrypted_columns_with_different_domains_get_no_config in manager.rs covers the loader, and write_to_a_column_with_no_config_is_refused in frontend.rs covers the refusal end to end.

One related gap remains and is not fixed here. Inside a transaction, a CREATE TABLE app.users (email eql_v3_text_search) updates the encrypt-config overlay under the same bare key, so until commit it can replace public.users's configuration. The reload after commit applies the rule above.

The same push also carries 21f623d, which fixes the red Test jobs: CREATE TABLE n …; INSERT INTO n … in one query was refused. A write now falls back only when every table it writes to is a native table this connection created.


Generated by Claude Code

@tobyhede
tobyhede requested a review from freshtonic October 23, 2026 00:19

Copy link
Copy Markdown
Contributor Author

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 freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:36 still 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 TABLE rows. 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 after CREATE TEMP TABLE t; DROP TABLE t, an INSERT INTO t that 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 encrypted t, 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.users matches the schema app, but "APP".users does not.
  • Names that do not resolve. A three-part name, or a write qualified with pg_catalog or with a schema outside the search path, is refused. A read from pg_catalog is forwarded.
  • A bare name in two schemas does not resolve, so a write through it is refused.
  • search_path changes 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.users is refused. A bare SELECT email FROM users returns 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_columns gate) 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.

Copy link
Copy Markdown
Contributor Author

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 select_table_schemas.sql. Keying the config by schema is a follow-up that I'll raise as a ticket. On native_tables not being cleared: agreed. #484's temporary-table layer replaces that set, and that's one reason these two merge together.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

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

Copy link
Copy Markdown
Contributor Author

"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 FATAL: could not get connection from the pool - AllServersDown while restoring client B. That's a flaky pooler, not something this change caused. I've re-run the failed job.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

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):

  1. feat(eql-mapper) schema-qualified table resolution
  2. fix(proxy) the loader records each table's schema and refuses conflicting domains
  3. fix(proxy) refuse writes to a table Proxy cannot resolve

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

tobyhede and others added 3 commits October 23, 2026 14:41
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>
@tobyhede
tobyhede force-pushed the claude/project-thread-2yjn9s branch from 6143e4e to e9d509a Compare October 23, 2026 03:41
@tobyhede
tobyhede merged commit 98c4460 into main Oct 23, 2026
@tobyhede
tobyhede deleted the claude/project-thread-2yjn9s branch October 23, 2026 03:41
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.

2 participants