Skip to content

Commit d3b4098

Browse files
authored
Merge pull request #441 from cipherstash/james/cip-3678-protocol-desync-transformation-error-after-a-mapped
fix(proxy): protocol desync on transformation error after a mapped statement (CIP-3678)
2 parents 2459536 + ff903c2 commit d3b4098

5 files changed

Lines changed: 272 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2424

2525
### Fixed
2626

27+
- **Statement errors no longer desync the connection**: when a statement failed inside the proxy (an unsupported operation on an encrypted column, for instance), the error was written straight to the client and could overtake responses still in flight from the server — with connection pools and prepared-statement caching, the client then saw a protocol error (`unexpected message from server` in tokio_postgres) instead of the proxy's message, typically right after an encrypted statement had run on the same connection. The proxy now delivers statement errors through the server, so clients always receive the proxy's actual error message, in order, and the connection remains usable.
28+
2729
- **A param bound as both a stored value and a query operand**: `UPDATE t SET enc = $1 WHERE enc = $1` failed with a domain CHECK violation. The two occurrences need different payloads — the stored one carries the ciphertext, the query one only search terms — but the role was tracked per input param, so marking the param as a query operand stripped the ciphertext from the value being stored. The role is now taken from the rewritten statement, per occurrence.
2830

2931
- **JSON selector params when the client declares its own types**: a client that sends param OIDs in Parse (pgx in `cache_describe` mode, for example) got `function eql_v3.jsonb_path_exists(eql_v3_json_search, jsonb) does not exist`. A JSON field selector is passed to the rewritten function as bare text, but was being declared as `jsonb` like every other encrypted operand. Affects `->`, `->>`, `jsonb_path_exists`, `jsonb_path_query` and `jsonb_path_query_first`.

packages/cipherstash-proxy-integration/src/common.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ use tracing::info;
4747
use tracing_subscriber::{filter::Directive, EnvFilter, FmtSubscriber};
4848

4949
pub const PROXY: u16 = 6432;
50+
51+
/// Proxy port for tests: `CS_PROXY__PORT` if set, otherwise [`PROXY`].
52+
///
53+
/// Lets a local run target a proxy on a non-default port without patching the
54+
/// test source (mirrors [`get_database_port`] for `CS_DATABASE__PORT`).
55+
pub fn proxy_port() -> u16 {
56+
std::env::var("CS_PROXY__PORT")
57+
.ok()
58+
.and_then(|s| s.parse().ok())
59+
.unwrap_or(PROXY)
60+
}
5061
pub const PROXY_METRICS_PORT: u16 = 9930;
5162
pub const PG_PORT: u16 = 5532;
5263
pub const PG_TLS_PORT: u16 = 5617;

packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,21 @@
22
mod tests {
33
use tracing::{debug, info};
44

5-
use crate::common::{clear, connect_with_tls, random_id, reset_schema, trace, PROXY};
5+
use crate::common::{
6+
clear, connect_with_tls, proxy_port, random_id, reset_schema, trace, PROXY,
7+
};
8+
9+
/// A statement that always fails inside the proxy, at Parse, in every
10+
/// configuration: the proxy's SQL parser rejects it before it reaches the
11+
/// server (same shape as [`invalid_sql_statement`]).
12+
///
13+
/// A transformation failure (e.g. equality on the storage-only
14+
/// `eql_v3_boolean`) cannot be used here: type-check errors only surface
15+
/// when `CS_DEVELOPMENT__ENABLE_MAPPING_ERRORS` is on, and the CI proxy
16+
/// (like production) runs with it off, silently passing such statements
17+
/// through. A parse error takes the same failure path in the proxy
18+
/// (`handle_statement_error`) regardless of that flag.
19+
const FAILS_IN_PROXY: &str = "INSERT INTO encrypted id, encrypted_text VALUES ($1, $2)";
620

721
struct Reset;
822

@@ -108,6 +122,78 @@ mod tests {
108122
}
109123
}
110124

125+
/// CIP-3678 regression: a statement that fails inside the proxy on a
126+
/// connection that has already run a MAPPED (encrypted) statement must
127+
/// surface the proxy's own error as a clean `db error` — not desync the
128+
/// extended-protocol stream into a client-side protocol error
129+
/// (`unexpected message from server`) — and the connection must remain
130+
/// usable afterwards.
131+
#[tokio::test]
132+
async fn proxy_error_after_mapped_statement() {
133+
trace();
134+
135+
let client = connect_with_tls(proxy_port()).await;
136+
137+
// Mapped warm-up: an encrypted statement that parses, binds and
138+
// executes successfully.
139+
client
140+
.query(
141+
"SELECT id FROM encrypted WHERE encrypted_text = $1",
142+
&[&"cip-3678"],
143+
)
144+
.await
145+
.unwrap();
146+
147+
// A statement that fails inside the proxy must return the proxy's
148+
// error, delivered as a database error.
149+
let err = client
150+
.query(FAILS_IN_PROXY, &[&random_id(), &"cip-3678"])
151+
.await
152+
.unwrap_err();
153+
let db_err = err.as_db_error().unwrap_or_else(|| {
154+
panic!("expected a db error carrying the proxy's message, got: {err:?}")
155+
});
156+
assert!(
157+
db_err.message().contains("sql parser error"),
158+
"expected the proxy's parse error, got: {db_err:?}"
159+
);
160+
161+
// The connection must remain usable.
162+
let rows = client.query("SELECT 1::int4", &[]).await.unwrap();
163+
let one: i32 = rows[0].get(0);
164+
assert_eq!(one, 1);
165+
}
166+
167+
/// Companion to [`proxy_error_after_mapped_statement`]: the same failing
168+
/// statement on a connection that has only run passthrough statements.
169+
/// This path already worked; keep it covered.
170+
#[tokio::test]
171+
async fn proxy_error_after_passthrough_statement() {
172+
trace();
173+
174+
let client = connect_with_tls(proxy_port()).await;
175+
176+
// Passthrough warm-up.
177+
client.query("SELECT 1::int4", &[]).await.unwrap();
178+
179+
let err = client
180+
.query(FAILS_IN_PROXY, &[&random_id(), &"cip-3678"])
181+
.await
182+
.unwrap_err();
183+
let db_err = err.as_db_error().unwrap_or_else(|| {
184+
panic!("expected a db error carrying the proxy's message, got: {err:?}")
185+
});
186+
assert!(
187+
db_err.message().contains("sql parser error"),
188+
"expected the proxy's parse error, got: {db_err:?}"
189+
);
190+
191+
// The connection must remain usable.
192+
let rows = client.query("SELECT 1::int4", &[]).await.unwrap();
193+
let one: i32 = rows[0].get(0);
194+
assert_eq!(one, 1);
195+
}
196+
111197
#[tokio::test]
112198
async fn invalid_sql_statement() {
113199
trace();

0 commit comments

Comments
 (0)