Skip to content

test(integration): prove PostgreSQL rejects plaintext on stale routes - #489

Merged
tobyhede merged 2 commits into
mainfrom
claude/project-thread-8h3rpk
Oct 26, 2026
Merged

tobyhede merged 2 commits into
mainfrom
claude/project-thread-8h3rpk

Conversation

@tobyhede

@tobyhede tobyhede commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Toby

Proxy routes a prepared statement once, at Parse, and a transaction routes against the schema pinned when it started.
If another connection's committed DDL makes a column encrypted in between, Proxy forwards the write as written, with plaintext.
We decided not to change routing for this and to rely on PostgreSQL instead: an EQL v3 column is a domain over jsonb whose CHECK accepts only an EQL payload.
This PR proves that guard with integration tests and documents it.

Changes

New packages/cipherstash-proxy-integration/src/postgres_rejects_plaintext.rs, run through Proxy:

  • Stale prepared statement: A prepares INSERT ... VALUES ($1, $2) while secret is jsonb, B runs ALTER TABLE ... ALTER COLUMN secret TYPE eql_v3_text_search, then A executes with plaintext.
  • Pinned transaction: A runs BEGIN and reads an unrelated table, B makes secret encrypted, then A sends the INSERT as a simple Query.
  • Statement prepared with mapping disabled: prepared under SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true, executed after it is turned off again. Here Proxy, not PostgreSQL, is the guard: it refuses the Bind because the mapping setting changed since the Parse, so this test asserts Proxy's refusal.

The other two assert PostgreSQL's own error (SQLSTATE and message) and, directly against PostgreSQL, that the table is empty.
Each writes a JSON-shaped plaintext ({"name": "alice"}), which parses as jsonb, so the domain CHECK is what rejects it.

packages/cipherstash-proxy/CONTEXT.md gains a Stale route term that records the reliance on PostgreSQL's domain check and points at the tests.

Notes

  • The integration suite already runs at the production default for mapping errors: CS_DEVELOPMENT__ENABLE_MAPPING_ERRORS is set in tests/mise.*.toml but not passed into the proxy containers in tests/docker-compose.yml, and it defaults to off. None of these routes depends on it anyway.
  • A COPY case was dropped after fix(proxy): refuse COPY FROM into encrypted columns, check statements after COPY FROM STDIN #488 merged: Proxy now refuses COPY ... FROM into an encrypted column itself, and copy_from_stdin.rs covers that.
  • Verified locally through a Proxy built from this branch against PostgreSQL 17 with EQL 3.0.4 (ZeroKMS start-up check stubbed out locally; these tests never encrypt). Against PostgreSQL alone, the PostgreSQL-guard tests fail when the column stays plain jsonb.

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 25, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T01:06:33.767085Z 59f893b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: automated /code-review medium, with findings cross-checked against this PR's existing feedback (cross-check-review-findings), at head 59f893b.

Critical / blocking: none.

Findings: none survived verification. The only existing feedback is a Codex status comment, with no findings to overlap.

Checked against head:

  • The helpers the tests use (connect, connect_with_tls, get_database_port, random_id, PROXY) exist in common.rs.
  • The bytes dependency is already declared, and the new futures-util is already locked at 0.3.31.
  • The SQLSTATE and message expectations match PostgreSQL's domain CHECK and jsonb input errors.
  • Each test also checks directly against PostgreSQL that the table is empty.

Not done: I did not compile or run the integration tests, which need PostgreSQL, EQL and Proxy. CI is the check for that.


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.

Reviewed head 02b9b606. The tests are good: each one first shows that Proxy chose the native route (for example, insert.params() == [INT8, JSONB]), and then it checks the table directly against PostgreSQL. But the CONTEXT.md entry says more than the tests show, and one test conflicts with #488.

Should fix

S1. The Stale route entry describes a guard for writes only, but it is written as a general rule.

  • CONTEXT.md says that neither a prepared statement nor a pinned transaction "sees a column that became encrypted", and that "a plaintext forwarded on a stale route is stopped by PostgreSQL". The domain CHECK runs only when a value is stored in the column.
  • The same stale route also applies to reads and predicates, and there PostgreSQL stops nothing:
    • A prepared SELECT secret FROM t, routed at Parse while secret was jsonb, returns the EQL ciphertext to the client without decryption.
    • A prepared SELECT … WHERE secret = $1 sends the plaintext parameter to PostgreSQL. The comparison then runs with jsonb semantics over the ciphertext and silently matches nothing.
  • Neither case stores plaintext. But the first returns ciphertext undecrypted, and the second puts plaintext in a statement that the database can log.
  • Fix: make the entry say that the domain CHECK guards writes. Record the read and predicate behaviour as a known limitation of stale routes, so that no one later reads this entry as "stale routes are safe".

S2. Merge hazard with #488.

  • #488 makes Proxy refuse COPY … FROM into an encrypted column (CopyIntoEncryptedColumn), and it also accepts COPY … FROM STDIN without the trailing ;.
  • After #488 merges, copy_from_stdin_into_encrypted_column_is_rejected_by_postgres fails: Proxy refuses the Parse, so copy_in(…).await.unwrap() panics. The two PRs merge cleanly as text, so only the integration run will show it.
  • These lines also become false: the CONTEXT.md sentence that the domain CHECK "is what stops plaintext in … COPY … FROM STDIN", the module doc ("Proxy also forwards some writes it never type checks"), and the comment about the trailing ;.
  • Fix: if #488 merges first, rebase, then delete this test (copy_from_stdin.rs in #488 covers the refusal) or change it to expect Proxy's error, and remove the COPY text. If this PR merges first, #488 must do the same.

Nits

  • "an EQL column is a domain over jsonb" is true for EQL v3 only. A legacy eql_v2_encrypted column is a composite type, and the CHECK on it exists only when eql_v2.add_encrypted_constraint was run. Proxy refuses v2 columns when it knows about them, but a stale route does not know. Write "an EQL v3 column".
  • connect_for_test copies the TLS switch from schema_change.rs. Both modules now have it, so it can move to common.

What I checked and found correct

  • The mapping-disabled test correctly asserts Proxy's own refusal (26000, "no longer match") and not a PostgreSQL error. The prepare_typed with JSONB is needed and is explained.
  • Both rejection paths are covered: a JSON-shaped plaintext gives 23514 from the domain CHECK, and a non-JSON plaintext gives 22P02. So the tests prove that the CHECK, not jsonb parsing, rejects the value.
  • The pinned-transaction test reads an unrelated table before the DDL, so it tests the pinned snapshot and not a cold start. It rolls back before it checks the table.
  • CI: all 9 checks pass.

Copy link
Copy Markdown
Contributor Author

Thanks @freshtonic. Pushing one fast-forward commit now (no rewrite) that addresses this review:

I ran the four tests through a Proxy built from this branch against PostgreSQL 17 with EQL 3.0.4, and all four pass.


Generated by Claude Code

@tobyhede
tobyhede force-pushed the claude/project-thread-8h3rpk branch from 26116c3 to 8f8bbe1 Compare October 26, 2026 00:42
@tobyhede
tobyhede requested a review from freshtonic October 26, 2026 00:43

Copy link
Copy Markdown
Contributor Author

Force-pushing now. The branch keeps the same base and the same tree for the existing commits, plus one new commit:

I ran the three remaining tests and copy_from_stdin through a Proxy built from this branch, and all of them pass.


Generated by Claude Code

@tobyhede
tobyhede force-pushed the claude/project-thread-8h3rpk branch from 8f8bbe1 to 08e74bd Compare October 26, 2026 00:46
Move connect_for_test from schema_change.rs into common.rs so other
integration tests can connect the same way as the Proxy under test.
@tobyhede
tobyhede force-pushed the claude/project-thread-8h3rpk branch from 08e74bd to b769e2c Compare October 26, 2026 00:55

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

Re-reviewed head b769e2c8, rebased onto main after #488 merged. (I reviewed 08e74bd7. b769e2c8 has the same tree on the same base, with the history squashed into two commits.) I read the full diff against main and the delta from 02b9b606. All the items from my last review are fixed:

  • S1: the Stale route entry and the module doc now say that the domain CHECK guards writes only. The entry records stale reads (ciphertext returned undecrypted) and stale predicates (plaintext sent to PostgreSQL) as a known limitation.
  • S2: the COPY test, the COPY line in the module doc, the comment about the trailing ; and the COPY sentence in CONTEXT.md are removed. copy_from_stdin.rs from #488 now covers the refusal. The futures-util dependency also left this diff, because #488 already added it.
  • Nits: the text now says "EQL v3 column", and connect_for_test moved to common.rs, where schema_change.rs also uses it.

The three remaining tests did not change, apart from the shared connect_for_test. Both commits show as verified on GitHub.

Nit

  • lib.rs:32: the module doc still says "PostgreSQL rejecting plaintext that Proxy forwards on a stale or unchecked route". With the COPY case gone, every test is about a stale route, and the mapping-disabled test is about Proxy's own refusal. Something like "Guards against plaintext on a stale route" is more accurate.

CI

All 9 checks pass on b769e2c8, including the integration jobs on PostgreSQL 14 to 17 and the encrypted burn-in.

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review. Pushing one fast-forward commit for the lib.rs nit: the module doc now reads "Guards against plaintext written on a stale route". It's doc-only, with no rewrite.


Generated by Claude Code

Proxy routes a prepared statement at Parse and a transaction against the
schema pinned when it started, so DDL committed by another connection can
make a column encrypted after Proxy chose to forward a write to it.

Add integration tests showing that PostgreSQL rejects the plaintext
through the EQL domain's CHECK in both cases, and that nothing is stored.
A JSON-shaped plaintext shows the CHECK, not jsonb parsing, does the
rejecting. A statement prepared with mapping disabled is refused by Proxy
at Bind once mapping is re-enabled, so it never reaches PostgreSQL.

Record the stale route in the Proxy context, including the known
limitation that the CHECK guards writes only, not reads or predicates.
@tobyhede
tobyhede force-pushed the claude/project-thread-8h3rpk branch from 72448e5 to 79ff83d Compare October 26, 2026 05:02
@tobyhede
tobyhede merged commit 0f4924f into main Oct 26, 2026
9 checks passed
@tobyhede
tobyhede deleted the claude/project-thread-8h3rpk branch October 26, 2026 05:38
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