Skip to content

Commit c0b5eb7

Browse files
committed
Harden security, performance, and DoS resilience across workspace - Fix TOCTOU race in vault permission check (fstat on fd, not path)
- Add constant-time token comparison via subtle::ConstantTimeEq - Use Docker file-based secrets instead of env vars for passphrase - Validate config paths (absolute only, reject ..) via garde - Add JSON depth check (max 64) on webhook payloads DoS hardening closes 5 resource exhaustion vectors: - Cap actions per rule (100) and condition siblings per node (100) - Enforce minimum cron interval (60s) to prevent per-second abuse - Limit WriteFile action content to 10 MiB - Cap rule count at 10,000 per instance - Non-blocking webhook dispatch (try_send, 503 on full channel) Performance fixes eliminate hot-path waste: - Cache compiled regexes at add_rule() time (HashMap lookup vs recompile) - Arc-wrap RuleMatch payload and actions (ref-count bump vs deep clone) - Arc-wrap NativeConnectorHost in registry, derive Clone on CapabilityChecker — drop registry lock before connector network calls - Drop engine lock before cron/watcher unschedule in rule update/delete API input validation: - Clamp event query limit to 10,000 - Reject path parameters longer than 256 chars - Bound event query limit on all endpoints Shared module extraction (code deduplication): -deserialize_secret/deserialize_secret_option in springtale-connector - base64url_encode/urlencoded encoding helpers - handle_json_response for typed API clients - derive_api_token_hash in springtale-crypto - Per-connector client::test_helpers mock modules
1 parent 910e5c4 commit c0b5eb7

61 files changed

Lines changed: 1333 additions & 826 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ argon2 = "0.5"
6868
rand = { version = "0.8", features = ["getrandom", "std_rng"] }
6969
sha2 = "0.10"
7070
hmac = "0.12"
71+
subtle = "2"
7172

7273
# ── Identifiers ────────────────────────────────────────────────────────────────
7374
uuid = { version = "1", features = ["v4", "serde"] }

README.md

Lines changed: 77 additions & 123 deletions
Large diffs are not rendered by default.

apps/springtale-cli/src/commands/init.rs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use springtale_store::backend::sqlite::SqliteBackend;
88

99
/// Initialize Springtale: create data directory, vault, SQLite database, and default config.
1010
pub async fn run() -> Result<()> {
11-
let data_dir = data_dir();
11+
let data_dir = springtale_store::paths::data_dir();
1212
println!("Initializing Springtale in {}", data_dir.display());
1313

1414
// Create data directory
@@ -96,12 +96,3 @@ bind = "127.0.0.1:8080"
9696
Ok(())
9797
}
9898

99-
fn data_dir() -> PathBuf {
100-
std::env::var_os("XDG_DATA_HOME")
101-
.map(PathBuf::from)
102-
.or_else(|| {
103-
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))
104-
})
105-
.map(|base| base.join("springtale"))
106-
.unwrap_or_else(|| PathBuf::from(".springtale"))
107-
}

apps/springtale-cli/src/commands/rule.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ pub async fn run(action: RuleAction, store: &SqliteBackend, json: bool) -> Resul
8787

8888
// Load into engine and evaluate
8989
let mut engine = RuleEngine::new();
90-
engine.add_rule(rule);
90+
engine.add_rule(rule)?;
9191
let matches = engine.evaluate(&event);
9292

9393
if matches.is_empty() {

apps/springtale-cli/src/main.rs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,16 @@ fn open_store() -> Result<SqliteBackend> {
6262
}
6363
#[derive(serde::Deserialize, Default)]
6464
struct StoreSection {
65-
#[serde(default = "default_db_path")]
65+
#[serde(default = "springtale_store::paths::default_db_path")]
6666
path: std::path::PathBuf,
6767
}
6868

6969
let config: PartialConfig = figment.extract().unwrap_or_default();
7070
config.store.path
7171
} else {
72-
default_db_path()
72+
springtale_store::paths::default_db_path()
7373
};
7474

7575
SqliteBackend::open(&store_path)
7676
.map_err(|e| anyhow::anyhow!("failed to open store at {}: {e}", store_path.display()))
7777
}
78-
79-
fn default_db_path() -> std::path::PathBuf {
80-
std::env::var_os("XDG_DATA_HOME")
81-
.map(std::path::PathBuf::from)
82-
.or_else(|| {
83-
std::env::var_os("HOME")
84-
.map(|home| std::path::PathBuf::from(home).join(".local/share"))
85-
})
86-
.map(|base| base.join("springtale/springtale.db"))
87-
.unwrap_or_else(|| std::path::PathBuf::from(".springtale/springtale.db"))
88-
}

apps/springtaled/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ anyhow = { workspace = true }
1313
axum = { workspace = true }
1414
garde = { workspace = true }
1515
chrono = { workspace = true }
16+
subtle = { workspace = true }
1617
uuid = { workspace = true }
1718
tower = { workspace = true }
1819
tower-http = { workspace = true }

apps/springtaled/src/api/auth.rs

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use axum::extract::State;
22
use axum::http::{Request, StatusCode};
33
use axum::middleware::Next;
44
use axum::response::Response;
5+
use subtle::ConstantTimeEq;
56

67
use super::state::AppState;
78

@@ -12,7 +13,8 @@ use super::state::AppState;
1213
/// hash, which the user derives from their vault passphrase. This avoids
1314
/// managing a separate API key.
1415
///
15-
/// Verification uses constant-time comparison to prevent timing attacks.
16+
/// Verification uses `subtle::ConstantTimeEq` (RustCrypto audited) to
17+
/// prevent timing attacks.
1618
pub async fn require_auth(
1719
State(state): State<AppState>,
1820
request: Request<axum::body::Body>,
@@ -30,23 +32,12 @@ pub async fn require_auth(
3032

3133
let token_bytes = hex::decode(token).map_err(|_| StatusCode::UNAUTHORIZED)?;
3234

33-
// Constant-time comparison: the token IS the hash derived from the passphrase.
34-
// The client computes it the same way the server did at boot time.
35-
if token_bytes.len() != 32 || !constant_time_eq(&token_bytes, &state.api_token_hash) {
35+
// Constant-time comparison via `subtle` crate (RustCrypto audited).
36+
// The token IS the hash derived from the passphrase — client computes
37+
// it the same way the server did at boot time.
38+
if token_bytes.len() != 32 || bool::from(!token_bytes.ct_eq(&state.api_token_hash)) {
3639
return Err(StatusCode::UNAUTHORIZED);
3740
}
3841

3942
Ok(next.run(request).await)
4043
}
41-
42-
/// Constant-time byte comparison to prevent timing attacks.
43-
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
44-
if a.len() != b.len() {
45-
return false;
46-
}
47-
let mut diff = 0u8;
48-
for (x, y) in a.iter().zip(b.iter()) {
49-
diff |= x ^ y;
50-
}
51-
diff == 0
52-
}

apps/springtaled/src/api/connectors.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub async fn remove(
2929
State(state): State<AppState>,
3030
Path(name): Path<String>,
3131
) -> Result<impl IntoResponse, StatusCode> {
32+
super::validate_path_param(&name)?;
3233
let mut registry = state.registry.write().await;
3334
registry
3435
.remove(&name)
@@ -42,6 +43,7 @@ pub async fn enable(
4243
State(state): State<AppState>,
4344
Path(name): Path<String>,
4445
) -> Result<impl IntoResponse, StatusCode> {
46+
super::validate_path_param(&name)?;
4547
let mut registry = state.registry.write().await;
4648
registry
4749
.enable(&name)
@@ -55,6 +57,7 @@ pub async fn disable(
5557
State(state): State<AppState>,
5658
Path(name): Path<String>,
5759
) -> Result<impl IntoResponse, StatusCode> {
60+
super::validate_path_param(&name)?;
5861
let mut registry = state.registry.write().await;
5962
registry
6063
.disable(&name)

apps/springtaled/src/api/events.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ fn default_limit() -> u32 {
2222
50
2323
}
2424

25+
/// Maximum events per request. Prevents OOM from unbounded queries.
26+
const MAX_EVENT_LIMIT: u32 = 10_000;
27+
2528
/// GET /events — paginated event log.
2629
///
2730
/// Returns recent events (trigger type, connector, timestamp, action taken).
@@ -30,9 +33,11 @@ pub async fn list(
3033
State(state): State<AppState>,
3134
Query(params): Query<EventsQuery>,
3235
) -> impl IntoResponse {
36+
let clamped_limit = params.limit.min(MAX_EVENT_LIMIT);
37+
3338
let filter = EventFilter {
3439
connector_name: params.connector.clone(),
35-
limit: Some(params.limit),
40+
limit: Some(clamped_limit),
3641
offset: if params.offset > 0 { Some(params.offset) } else { None },
3742
..Default::default()
3843
};
@@ -42,7 +47,7 @@ pub async fn list(
4247
match events {
4348
Ok(events) => Json(serde_json::json!({
4449
"events": events,
45-
"limit": params.limit,
50+
"limit": clamped_limit,
4651
"offset": params.offset,
4752
})),
4853
Err(_) => Json(serde_json::json!({

0 commit comments

Comments
 (0)