Skip to content

Commit 22e70d2

Browse files
committed
security: isolate non-pgwire connection failures
1 parent b51678b commit 22e70d2

30 files changed

Lines changed: 1439 additions & 406 deletions

nodedb/src/control/server/http/routes/crdt.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ pub async fn crdt_apply(
115115
// replication. A local-only dispatch would land it on the receiving node only
116116
// — lost to followers and entirely on leader failover. This handler is scoped
117117
// to the default database (matching its surrogate assignment above).
118-
state.shared.tenant_request_start(identity.tenant_id);
118+
let _request = state.shared.tenant_request_guard(identity.tenant_id);
119119
let policy = crate::control::crdt_post_image_policy::ExternalCrdtPostImagePolicy::from_identity(
120120
identity.tenant_id,
121121
crate::types::DatabaseId::DEFAULT,
@@ -138,7 +138,6 @@ pub async fn crdt_apply(
138138
},
139139
)
140140
.await;
141-
state.shared.tenant_request_end(identity.tenant_id);
142141

143142
result.map_err(ApiError::from)?;
144143

nodedb/src/control/server/http/routes/query/materialized.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,12 @@ pub async fn query(
129129
}
130130

131131
// Track active request for quota accounting.
132-
state.shared.tenant_request_start(tenant_id);
132+
let _request = state.shared.tenant_request_guard(tenant_id);
133133

134134
// Execute each task via the SPSC bridge.
135135
let mut result_rows = Vec::new();
136136

137-
let result = async {
137+
async {
138138
for (task, authorized_task) in tasks.into_iter().zip(authorized_tasks) {
139139
// `INSERT ... SELECT` is orchestrated on the Control Plane: the
140140
// source is scanned, each target row gets its OWN fresh, registered
@@ -308,10 +308,7 @@ pub async fn query(
308308

309309
Ok(axum::Json(HttpQueryResponse::ok(result_rows)))
310310
}
311-
.await;
312-
313-
state.shared.tenant_request_end(tenant_id);
314-
result
311+
.await
315312
}
316313

317314
fn ddl_error_to_api(error: crate::control::server::shared::ddl::DdlError) -> ApiError {

nodedb/src/control/server/http/routes/query/ndjson.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ pub async fn query_ndjson(
152152
Err(error) => return ApiError::from(crate::Error::from(error)).into_response(),
153153
};
154154

155-
state.shared.tenant_request_start(tenant_id);
155+
let _request = state.shared.tenant_request_guard(tenant_id);
156156

157157
let mut ndjson = String::new();
158158
for (task, authorized_task) in tasks.into_iter().zip(authorized_tasks) {
@@ -262,8 +262,6 @@ pub async fn query_ndjson(
262262
}
263263
}
264264

265-
state.shared.tenant_request_end(tenant_id);
266-
267265
Response::builder()
268266
.header("Content-Type", "application/x-ndjson")
269267
.body(axum::body::Body::from(ndjson))

nodedb/src/control/server/http/routes/ws_rpc/execute_sql.rs

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ pub async fn execute_sql(
7070
)?
7171
.into_tasks();
7272

73-
shared.tenant_request_start(tenant_id);
73+
let _request = shared.tenant_request_guard(tenant_id);
7474

7575
let mut results = Vec::new();
7676
for (task, authorized_task) in tasks.into_iter().zip(authorized_tasks) {
@@ -98,10 +98,7 @@ pub async fn execute_sql(
9898
}
9999
}
100100
}
101-
Err(e) => {
102-
shared.tenant_request_end(tenant_id);
103-
return Err(e);
104-
}
101+
Err(e) => return Err(e),
105102
}
106103
continue;
107104
}
@@ -138,10 +135,7 @@ pub async fn execute_sql(
138135
}
139136
}
140137
}
141-
Err(e) => {
142-
shared.tenant_request_end(tenant_id);
143-
return Err(e);
144-
}
138+
Err(e) => return Err(e),
145139
}
146140
continue;
147141
}
@@ -182,10 +176,7 @@ pub async fn execute_sql(
182176
}
183177
}
184178
}
185-
Err(e) => {
186-
shared.tenant_request_end(tenant_id);
187-
return Err(e);
188-
}
179+
Err(e) => return Err(e),
189180
}
190181
continue;
191182
}
@@ -225,15 +216,10 @@ pub async fn execute_sql(
225216
}
226217
}
227218
}
228-
Err(e) => {
229-
shared.tenant_request_end(tenant_id);
230-
return Err(e);
231-
}
219+
Err(e) => return Err(e),
232220
}
233221
}
234222

235-
shared.tenant_request_end(tenant_id);
236-
237223
match results.len() {
238224
0 => Ok(serde_json::Value::Null),
239225
1 => Ok(results

nodedb/src/control/server/http/routes/ws_rpc/handler.rs

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,20 @@
2121
2222
use std::sync::Arc;
2323

24+
use tokio::task::JoinHandle;
25+
2426
use axum::extract::ws::{Message, WebSocket};
2527
use axum::extract::{State, WebSocketUpgrade};
2628
use axum::http::HeaderMap;
2729
use axum::response::IntoResponse;
2830
use futures::{SinkExt, StreamExt};
29-
use tracing::debug;
31+
use tracing::{debug, warn};
3032

3133
use crate::control::change_stream::LiveSubscriptionSet;
3234
use crate::control::security::audit::ArcAuditEmitter;
3335
use crate::control::server::http::auth::{AppState, ResolvedIdentity};
3436
use crate::control::server::shared::authorization::authorize_database;
37+
use crate::control::server::shared::{ConnectionFutureOutcome, isolate_connection_future};
3538
use crate::control::state::SharedState;
3639
use crate::types::DatabaseId;
3740

@@ -66,12 +69,64 @@ pub async fn ws_handler(
6669
.into_response();
6770
}
6871
let trace_id = crate::control::trace_context::extract_from_headers(&headers);
69-
ws.on_upgrade(move |socket| {
70-
handle_ws_connection(socket, state, identity, database_id, trace_id)
72+
ws.on_upgrade(move |socket| async move {
73+
match isolate_connection_future(handle_ws_connection(
74+
socket,
75+
state,
76+
identity,
77+
database_id,
78+
trace_id,
79+
))
80+
.await
81+
{
82+
ConnectionFutureOutcome::Completed(()) => {}
83+
ConnectionFutureOutcome::Panicked => {
84+
warn!("ws-rpc connection panicked");
85+
}
86+
}
7187
})
7288
.into_response()
7389
}
7490

91+
/// Owns a sender task until normal completion or connection teardown.
92+
///
93+
/// Dropping the guard aborts the task synchronously. This ensures a panic or
94+
/// cancellation of the connection future cannot detach the sender task.
95+
struct AbortOnDropJoinHandle {
96+
handle: Option<JoinHandle<()>>,
97+
}
98+
99+
impl AbortOnDropJoinHandle {
100+
fn new(handle: JoinHandle<()>) -> Self {
101+
Self {
102+
handle: Some(handle),
103+
}
104+
}
105+
106+
/// Join the sender after the channel is closed. A sender panic is observed
107+
/// without inspecting its payload or logging its join error.
108+
async fn finish(mut self) {
109+
let terminated_unexpectedly = match self.handle.as_mut() {
110+
Some(handle) => handle.await.is_err(),
111+
None => false,
112+
};
113+
// Remove only after the await completes. If this future is cancelled
114+
// while pending, `Drop` still owns and aborts the sender task.
115+
self.handle.take();
116+
if terminated_unexpectedly {
117+
warn!("ws-rpc sender task terminated unexpectedly");
118+
}
119+
}
120+
}
121+
122+
impl Drop for AbortOnDropJoinHandle {
123+
fn drop(&mut self) {
124+
if let Some(handle) = self.handle.take() {
125+
handle.abort();
126+
}
127+
}
128+
}
129+
75130
/// Handle a single WebSocket connection.
76131
async fn handle_ws_connection(
77132
socket: WebSocket,
@@ -87,7 +142,7 @@ async fn handle_ws_connection(
87142
// 256 messages provides ~10s of buffer at 25 events/sec.
88143
let (live_tx, mut live_rx) = tokio::sync::mpsc::channel::<String>(256);
89144

90-
let send_handle = tokio::spawn(async move {
145+
let sender = AbortOnDropJoinHandle::new(tokio::spawn(async move {
91146
loop {
92147
tokio::select! {
93148
Some(msg) = live_rx.recv() => {
@@ -99,7 +154,7 @@ async fn handle_ws_connection(
99154
else => break,
100155
}
101156
}
102-
});
157+
}));
103158

104159
// Session ID is set only after successful auth inside process_message.
105160
let mut authenticated_session_id: Option<String> = None;
@@ -157,7 +212,7 @@ async fn handle_ws_connection(
157212
drop(live_set);
158213

159214
drop(live_tx);
160-
let _ = send_handle.await;
215+
sender.finish().await;
161216
debug!("WebSocket RPC connection closed");
162217
}
163218

@@ -189,3 +244,88 @@ pub fn save_ws_session(shared: &SharedState, session_id: &str) {
189244
"WS session saved for reconnect"
190245
);
191246
}
247+
248+
#[cfg(test)]
249+
mod tests {
250+
use std::future::pending;
251+
252+
use tokio::sync::oneshot;
253+
254+
use super::{AbortOnDropJoinHandle, ConnectionFutureOutcome, isolate_connection_future};
255+
256+
struct AbortSignal(Option<oneshot::Sender<()>>);
257+
258+
impl Drop for AbortSignal {
259+
fn drop(&mut self) {
260+
if let Some(sender) = self.0.take() {
261+
let _ = sender.send(());
262+
}
263+
}
264+
}
265+
266+
#[tokio::test]
267+
async fn sender_is_aborted_when_guard_is_dropped() {
268+
let (started_tx, started_rx) = oneshot::channel();
269+
let (dropped_tx, dropped_rx) = oneshot::channel();
270+
let sender = AbortOnDropJoinHandle::new(tokio::spawn(async move {
271+
let _signal = AbortSignal(Some(dropped_tx));
272+
let _ = started_tx.send(());
273+
pending::<()>().await;
274+
}));
275+
276+
assert!(started_rx.await.is_ok());
277+
drop(sender);
278+
279+
assert!(dropped_rx.await.is_ok());
280+
}
281+
282+
#[tokio::test]
283+
async fn cancellation_while_finishing_aborts_sender() {
284+
let (started_tx, started_rx) = oneshot::channel();
285+
let (dropped_tx, dropped_rx) = oneshot::channel();
286+
let sender = AbortOnDropJoinHandle::new(tokio::spawn(async move {
287+
let _signal = AbortSignal(Some(dropped_tx));
288+
let _ = started_tx.send(());
289+
pending::<()>().await;
290+
}));
291+
assert!(started_rx.await.is_ok());
292+
293+
let finishing = tokio::spawn(sender.finish());
294+
tokio::task::yield_now().await;
295+
finishing.abort();
296+
let _ = finishing.await;
297+
298+
assert!(dropped_rx.await.is_ok());
299+
}
300+
301+
#[tokio::test]
302+
async fn sender_finishes_normally() {
303+
let (completed_tx, completed_rx) = oneshot::channel();
304+
let sender = AbortOnDropJoinHandle::new(tokio::spawn(async move {
305+
let _ = completed_tx.send(());
306+
}));
307+
308+
sender.finish().await;
309+
310+
assert!(completed_rx.await.is_ok());
311+
}
312+
313+
#[tokio::test]
314+
async fn isolation_catches_connection_panic_and_releases_sender() {
315+
let (started_tx, started_rx) = oneshot::channel();
316+
let (dropped_tx, dropped_rx) = oneshot::channel();
317+
let outcome = isolate_connection_future(async move {
318+
let _sender = AbortOnDropJoinHandle::new(tokio::spawn(async move {
319+
let _signal = AbortSignal(Some(dropped_tx));
320+
let _ = started_tx.send(());
321+
pending::<()>().await;
322+
}));
323+
let _ = started_rx.await;
324+
panic!("simulated websocket connection panic");
325+
})
326+
.await;
327+
328+
assert!(matches!(outcome, ConnectionFutureOutcome::Panicked));
329+
assert!(dropped_rx.await.is_ok());
330+
}
331+
}

nodedb/src/control/server/ilp_batch.rs

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -191,30 +191,11 @@ pub(crate) async fn flush_authenticated_ilp_batch(
191191
// all collection permissions have passed. The tenant is never caller input.
192192
let tenant_id = identity.tenant_id;
193193
state.check_tenant_quota(tenant_id)?;
194-
let _request = TenantRequestAccounting::start(state, tenant_id);
194+
let _request = state.tenant_request_guard(tenant_id);
195195

196196
flush_ilp_batch_inner(state, identity, database_id, groups).await
197197
}
198198

199-
/// Cancellation-safe tenant request accounting for one ILP batch.
200-
struct TenantRequestAccounting<'a> {
201-
state: &'a SharedState,
202-
tenant_id: TenantId,
203-
}
204-
205-
impl<'a> TenantRequestAccounting<'a> {
206-
fn start(state: &'a SharedState, tenant_id: TenantId) -> Self {
207-
state.tenant_request_start(tenant_id);
208-
Self { state, tenant_id }
209-
}
210-
}
211-
212-
impl Drop for TenantRequestAccounting<'_> {
213-
fn drop(&mut self) {
214-
self.state.tenant_request_end(self.tenant_id);
215-
}
216-
}
217-
218199
/// Inner dispatch logic for ILP batch (separated for clean quota bookkeeping).
219200
async fn flush_ilp_batch_inner(
220201
state: &SharedState,

0 commit comments

Comments
 (0)