Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **Schema-qualified table names**: Proxy now resolves a schema-qualified name such as `public.users` to the table in that schema, so statements that use one are encrypted instead of failing type checking. This includes a table whose name is also used in another schema on the search path. Proxy keys encryption configuration by the unqualified table name, so when same-named tables in different schemas declare a column with different EQL domains, statements on that column are refused rather than encrypted with the other table's configuration. Same-named tables that declare the same domain share one configuration and key descriptor, so their ciphertexts and search terms are interchangeable.

- **Prepared statement recovery and settings isolation**: failed or discarded protocol messages no longer replace encryption metadata for statements PostgreSQL retained. Pipelined close-and-reprepare recovers immediately, and refused operations use names that remain unpredictable within PostgreSQL's lookup limit. Unsupported keyset values fail explicitly, and stale prepared statements signal driver cache invalidation.

- **Ordinary transaction and COPY traffic**: transaction and reset batches that do not change effective CipherStash settings remain usable, and COPY input can progress while transaction outcomes are pending. Settings tracking avoids unnecessary transaction barriers and repeated SQL tree copies.
Expand Down Expand Up @@ -69,6 +71,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

- **Bind to a statement Proxy did not see prepared**: Proxy now refuses a Bind that names a prepared statement it has no Parse for on the connection, with PostgreSQL's own `26000` error, and the connection stays open. Proxy keeps encryption details only for statements that touch encrypted columns, so before this change it could not tell an unknown statement from a plaintext one and sent the bound values as they were. This includes statements created with SQL `PREPARE` and then bound through the extended protocol; prepare them with Parse instead. The check does not apply while mapping is disabled. See [Unknown prepared statement](docs/errors.md#mapping-unknown-prepared-statement).

- **Writes to a table Proxy cannot resolve**: when encrypted columns are configured, Proxy now refuses an `INSERT`, `UPDATE`, `DELETE` or `MERGE` that refers to a table missing from the database schema it has loaded, whether or not `CS_DEVELOPMENT__ENABLE_MAPPING_ERRORS` is set. Previously such a statement failed type checking and was forwarded as written, so its values could reach PostgreSQL in plaintext. A simple query is also no longer forwarded as written when one statement fails type checking and another needs encrypting. See [Unresolvable table](docs/errors.md#mapping-unresolvable-table).

Compatibility note: while encrypted columns are configured, Proxy now refuses writes to tables in schemas outside the search path it loads, to views and foreign tables (Proxy loads only base tables), and writes that use a bare table name found in more than one schema on that search path. Before upgrading, check that applications do not write to such tables through Proxy, or qualify the table name with its schema.

- **Short encryption results**: Proxy now refuses a statement when the encryption service returns fewer values than it was given, instead of sending the unmatched parameters unencrypted. See [Encryption result count mismatch](docs/errors.md#encrypt-result-count-mismatch).

- **Updated PostgreSQL client library**: Proxy is now built against `tokio-postgres` 0.7.18, which resolves [GHSA-3gjw-f78c-vvpw](https://github.com/advisories/GHSA-3gjw-f78c-vvpw). No configuration change is required.
Expand Down
40 changes: 40 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [Prepared settings changed](#mapping-prepared-settings-changed)
- [Unmodelled DDL](#mapping-unmodelled-ddl)
- [Unmappable encrypted column](#mapping-unmappable-encrypted-column)
- [Unresolvable table](#mapping-unresolvable-table)
- [Unknown prepared statement](#mapping-unknown-prepared-statement)
- [Internal Error](#mapping-internal-error)

Expand Down Expand Up @@ -404,6 +405,45 @@ If the column is no longer used, dropping it also resolves the error.



<!-- ---------------------------------------------------------------------------------------------------- -->


## Unresolvable table <a id='mapping-unresolvable-table'></a>

A statement that writes data refers to a table that is not in the database schema CipherStash Proxy has loaded.
Proxy cannot tell whether the statement writes to an encrypted column, so it refuses the statement rather than forward its values unencrypted.

### Error message

```
Statement refers to table 'users', which is not in the database schema Proxy has loaded, so Proxy cannot tell whether it writes to an encrypted column.
```

### Notes

The refusal applies to `INSERT`, `UPDATE`, `DELETE` and `MERGE`, including one inside `PREPARE`, `EXPLAIN` or a `WITH` clause, when any column is encrypted.
It applies wherever the statement names the table, so `INSERT INTO users ... SELECT ... FROM other` is refused when `other` cannot be resolved.
Read-only statements are not refused.
When no columns are encrypted, Proxy forwards these statements.

Unlike a type-check failure, this refusal is not subject to the `CS_DEVELOPMENT__ENABLE_MAPPING_ERRORS` passthrough.

Proxy loads the tables in the schemas on its database connection's search path.
A schema-qualified name such as `public.users` resolves only to a table loaded from that schema.
A bare table name used in more than one schema on the search path cannot be resolved, because the client's search path can differ from Proxy's.
Encryption configuration is keyed by the unqualified table name, so when same-named tables in different schemas declare a column with different EQL domains, Proxy loads no configuration for that column and refuses statements on it.
When they declare the same domain, both tables share one configuration and one key descriptor, so they are not cryptographically separate: a ciphertext copied from one table to the other still decrypts, and equal values produce equal search terms in both.
A table in a schema that is not on the search path, or one created on another connection since Proxy last loaded the schema, cannot be resolved.

### How to fix

Check that the table exists and that its name is spelled as PostgreSQL resolves it.
If the table name is used in more than one schema, qualify it with its schema, as in `public.users`.
If the table is in a schema outside the search path, add that schema to the search path of the database user Proxy connects as, then restart Proxy or wait for it to reload the schema.
If the table was created on another connection, retry once Proxy has reloaded the database schema.



<!-- ---------------------------------------------------------------------------------------------------- -->


Expand Down
7 changes: 5 additions & 2 deletions packages/cipherstash-proxy/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ _Avoid_: session (`start_session`, `SessionId` and the
`..._statements_session_duration_seconds` metric all use this sense and are misnamed).

**Passthrough**:
Traffic forwarded without encryption or rewriting. Three independent conditions produce
Traffic forwarded without encryption or rewriting. Four independent conditions produce
it and they are not interchangeable: no encrypt config is loaded, mapping is disabled by
configuration, or `SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING` was issued on this connection.
configuration, `SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING` was issued on this connection, or
the **type-check fallback** forwarded a statement EQL Mapper could not type check while
mapping errors are off. The fallback never applies to a refusal, and never to a simple
Query with another statement that needs mapping.
Name the condition rather than saying "passthrough" alone.

### Encryption
Expand Down
7 changes: 6 additions & 1 deletion packages/cipherstash-proxy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,11 @@ impl Error {
self,
// Forwarding a statement that references a legacy EQL v2 column
// stores plaintext in a column its operator believes is encrypted
// (CIP-3688). No configuration may turn that back on.
// (CIP-3688). No configuration may turn that back on. Nor may a
// write to a table Proxy cannot resolve be forwarded.
Error::Mapping(
MappingError::UnmappableEncryptedColumn { .. }
| MappingError::UnresolvableTable { .. }
| MappingError::DependentStatementAfterDdl
| MappingError::DependentStatementAfterSettings
| MappingError::PreparedSettingsChanged
Expand Down Expand Up @@ -153,6 +155,9 @@ pub enum MappingError {
#[error("Statement could not be type checked: {}. For help visit {}#mapping-statement-could-not-be-type-checked", _0, ERROR_DOC_BASE_URL)]
StatementCouldNotBeTypeChecked(String),

#[error("Statement refers to table '{table}', which is not in the database schema Proxy has loaded, so Proxy cannot tell whether it writes to an encrypted column. For help visit {}#mapping-unresolvable-table", ERROR_DOC_BASE_URL)]
UnresolvableTable { table: String },

#[error("Column '{table}.{column}' is declared as '{column_type}', a legacy EQL v2 type that this version of CipherStash Proxy cannot encrypt or decrypt. Statements referencing '{table}' are refused so that plaintext is never written to the column. Migrate '{table}.{column}' to an EQL v3 domain type. For help visit {}#mapping-unmappable-encrypted-column", ERROR_DOC_BASE_URL)]
UnmappableEncryptedColumn {
table: String,
Expand Down
24 changes: 24 additions & 0 deletions packages/cipherstash-proxy/src/postgresql/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1732,6 +1732,30 @@ where
Ok(())
}

/// Records the native tables a simple-query batch creates, so the batch's
/// later statements can write to them.
pub fn note_native_tables(&self, statements: &[sqltk::parser::ast::Statement]) {
self.schema_middleware.note_native_tables(statements);
}

/// Whether every table `statement` writes to is a native table this
/// connection created that the schema may not model yet.
pub fn writes_only_native_tables(&self, statement: &sqltk::parser::ast::Statement) -> bool {
eql_mapper::write_targets(statement).is_some_and(|targets| {
!targets.is_empty()
&& targets
.iter()
.all(|target| self.schema_middleware.is_native_table(target))
})
}

/// Whether any column is encrypted. A column can be encrypted without
/// encrypt config, when same-named tables disagree on its domain.
pub fn has_encrypted_columns(&self) -> bool {
!self.schema_middleware.encrypt_config().is_empty()
|| self.schema_middleware.schema_has_encrypted_columns()
}

pub fn is_passthrough(&self) -> bool {
self.schema_middleware.encrypt_config().is_empty() || self.config.mapping_disabled()
}
Expand Down
Loading
Loading