Skip to content

Commit 5319021

Browse files
committed
feat(security): fingerprint binding, rate limiting, and miss-spike detection on session handles
Refactor `session_handle.rs` into a module with four focused files: `fingerprint.rs`, `rate_limit.rs`, `miss_tracker.rs`, and `store.rs`. The store gains three new capabilities: - **Fingerprint binding** (`FingerprintMode`: Strict / Subnet / Disabled): `SessionHandleStore::create()` captures `(tenant_id, peer IP)` as a `ClientFingerprint`; subsequent resolves from a mismatched origin are rejected and emit a `SessionHandleFingerprintMismatch` audit event. - **Per-connection rate limiting**: `resolve()` now takes a `conn_key` string; each connection gets a sliding-window counter capped at `resolve_attempts_per_window`. Exceeding the cap returns `ResolveOutcome::RateLimited`, which the pgwire and HTTP handlers convert into a fatal SQLSTATE 53300 error that closes the connection. - **Miss-spike detection**: consecutive resolve misses on one connection within `miss_spike_window_secs` beyond `miss_spike_threshold` emit a `SessionHandleResolveMissSpike` audit event. `SessionHandleStore::resolve()` now returns `ResolveOutcome` (Resolved / Miss / RateLimited) instead of an `Option`; all call sites updated. Two new audit events are added (`SessionHandleFingerprintMismatch` = 23, `SessionHandleResolveMissSpike` = 24) at `AuditLevel::Standard`. `AuditLog` is moved from `Mutex<AuditLog>` to `Arc<Mutex<AuditLog>>` in `SharedState` so the session-handle store can hold a clone for its emission path without calling back into `SharedState`. A `wire_session_handle_audit` helper wires the audit hook at `new()` and `from_persistent()` time. `SessionHandleConfig` is added to `AuthConfig` (serde, defaults: 1h TTL, Subnet fingerprint mode, 20 resolves / 60 s window, 10-miss / 60 s spike threshold). `SharedState::from_persistent` constructs the store from config. HTTP server uses `into_make_service_with_connect_info` so the `POST /api/auth/session` route can extract `ConnectInfo<SocketAddr>` for fingerprint capture. pgwire handlers and `SET nodedb.auth_session` eager validation both pass `ClientFingerprint` and `conn_key` into resolve.
1 parent d14e440 commit 5319021

15 files changed

Lines changed: 1239 additions & 234 deletions

File tree

nodedb/src/config/auth.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,100 @@ pub struct AuthConfig {
271271
/// Usage metering configuration.
272272
#[serde(default, skip_serializing_if = "Option::is_none")]
273273
pub metering: Option<crate::control::security::metering::config::MeteringConfig>,
274+
275+
/// Opaque session handle configuration: fingerprint binding, resolve
276+
/// rate-limit, miss-spike detection. Issues #67 and #68.
277+
#[serde(default)]
278+
pub session: SessionHandleConfig,
279+
}
280+
281+
/// Configuration for `SessionHandleStore`: fingerprint binding, per-connection
282+
/// resolve rate limit, miss-spike detection. Maps directly to the acceptance
283+
/// criteria from issues #67 and #68.
284+
#[derive(Debug, Clone, Serialize, Deserialize)]
285+
pub struct SessionHandleConfig {
286+
/// Session handle TTL in seconds. Default: 3600 (1 hour).
287+
#[serde(default = "default_session_ttl_secs")]
288+
pub ttl_secs: u64,
289+
290+
/// Fingerprint-binding strictness. Default: `subnet` (#67).
291+
#[serde(default)]
292+
pub fingerprint_mode: SessionFingerprintMode,
293+
294+
/// Per-connection resolve attempts allowed within
295+
/// `rate_limit_window_secs`. Exceed → fatal pgwire error + connection
296+
/// close. Default: 20 (#68).
297+
#[serde(default = "default_session_rate_limit_max")]
298+
pub resolve_attempts_per_window: u32,
299+
300+
/// Sliding-window length for the rate limiter in seconds.
301+
/// Default: 60.
302+
#[serde(default = "default_session_rate_limit_window_secs")]
303+
pub rate_limit_window_secs: u64,
304+
305+
/// Number of resolve misses on a single connection within
306+
/// `miss_spike_window_secs` that triggers a
307+
/// `SessionHandleResolveMissSpike` audit event. Default: 10.
308+
#[serde(default = "default_session_miss_spike_threshold")]
309+
pub miss_spike_threshold: u32,
310+
311+
/// Sliding-window length for the spike detector in seconds.
312+
/// Default: 60.
313+
#[serde(default = "default_session_miss_spike_window_secs")]
314+
pub miss_spike_window_secs: u64,
315+
}
316+
317+
/// Serde-facing mirror of
318+
/// [`crate::control::security::session_handle::FingerprintMode`]. Two
319+
/// enums exist because the core type lives in the `security` module and
320+
/// the config module would pull it into the serde surface.
321+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
322+
#[serde(rename_all = "lowercase")]
323+
pub enum SessionFingerprintMode {
324+
Strict,
325+
#[default]
326+
Subnet,
327+
Disabled,
328+
}
329+
330+
impl From<SessionFingerprintMode> for crate::control::security::session_handle::FingerprintMode {
331+
fn from(m: SessionFingerprintMode) -> Self {
332+
use crate::control::security::session_handle::FingerprintMode as Core;
333+
match m {
334+
SessionFingerprintMode::Strict => Core::Strict,
335+
SessionFingerprintMode::Subnet => Core::Subnet,
336+
SessionFingerprintMode::Disabled => Core::Disabled,
337+
}
338+
}
339+
}
340+
341+
impl Default for SessionHandleConfig {
342+
fn default() -> Self {
343+
Self {
344+
ttl_secs: default_session_ttl_secs(),
345+
fingerprint_mode: SessionFingerprintMode::default(),
346+
resolve_attempts_per_window: default_session_rate_limit_max(),
347+
rate_limit_window_secs: default_session_rate_limit_window_secs(),
348+
miss_spike_threshold: default_session_miss_spike_threshold(),
349+
miss_spike_window_secs: default_session_miss_spike_window_secs(),
350+
}
351+
}
352+
}
353+
354+
fn default_session_ttl_secs() -> u64 {
355+
3600
356+
}
357+
fn default_session_rate_limit_max() -> u32 {
358+
20
359+
}
360+
fn default_session_rate_limit_window_secs() -> u64 {
361+
60
362+
}
363+
fn default_session_miss_spike_threshold() -> u32 {
364+
10
365+
}
366+
fn default_session_miss_spike_window_secs() -> u64 {
367+
60
274368
}
275369

276370
impl Default for AuthConfig {
@@ -289,6 +383,7 @@ impl Default for AuthConfig {
289383
jwt: None,
290384
rate_limit: None,
291385
metering: None,
386+
session: SessionHandleConfig::default(),
292387
}
293388
}
294389
}

nodedb/src/control/security/audit/event.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ pub enum AuditEvent {
6565
/// every replica from `MetadataCommitApplier` with full before /
6666
/// after descriptor versions + HLC + raw SQL text. (J.4)
6767
DdlChange = 22,
68+
/// Session handle resolve failed fingerprint check — caller's
69+
/// (tenant_id, ip) didn't match the fingerprint captured at
70+
/// `SessionHandleStore::create()`. Signals handle theft across
71+
/// origins even when the handle itself is otherwise valid.
72+
SessionHandleFingerprintMismatch = 23,
73+
/// Resolve-miss rate on a single connection crossed the configured
74+
/// threshold within the detection window. Signals enumeration
75+
/// attempts or misconfigured clients probing bogus handles.
76+
SessionHandleResolveMissSpike = 24,
6877
}
6978

7079
impl AuditEvent {
@@ -103,6 +112,9 @@ impl AuditEvent {
103112
Self::QueryExec | Self::RlsDenied => AuditLevel::Full,
104113
Self::RowChange => AuditLevel::Forensic,
105114
Self::DdlChange => AuditLevel::Standard,
115+
Self::SessionHandleFingerprintMismatch | Self::SessionHandleResolveMissSpike => {
116+
AuditLevel::Standard
117+
}
106118
}
107119
}
108120
}

nodedb/src/control/security/session_handle.rs

Lines changed: 0 additions & 198 deletions
This file was deleted.

0 commit comments

Comments
 (0)