diff --git a/.env.test b/.env.test index 878e4cc..0433e03 100644 --- a/.env.test +++ b/.env.test @@ -1 +1,3 @@ DATABASE_URL="postgres://tracevault_test:tracevault_test@localhost:5433/tracevault_test" +CORS_ORIGIN=http://localhost:4000 +GITHUB_WEBHOOK_SECRET=test-webhook-secret diff --git a/Cargo.lock b/Cargo.lock index 768404f..494aeff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,7 +457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -497,6 +497,20 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.7.10" @@ -576,7 +590,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -704,6 +718,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -760,6 +784,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -803,9 +833,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -852,6 +884,29 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" +[[package]] +name = "governor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "h2" version = "0.4.13" @@ -871,6 +926,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1461,6 +1522,24 @@ dependencies = [ "tempfile", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1481,7 +1560,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1637,7 +1716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1662,6 +1741,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1713,6 +1812,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1750,6 +1855,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.44" @@ -1772,8 +1892,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1783,7 +1913,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1795,6 +1935,24 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1821,7 +1979,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1920,7 +2078,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2158,7 +2316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2195,6 +2353,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -2246,7 +2413,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2322,7 +2489,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -2330,7 +2497,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2362,14 +2529,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2395,7 +2562,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2501,13 +2668,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2674,6 +2861,22 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower_governor" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a2ccff6830fa835371af7541e561a90e4c07b84f72991ebac4b3cb6790dc0d" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.18", + "tower", + "tracing", +] + [[package]] name = "tracevault-cli" version = "0.6.0" @@ -2703,7 +2906,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror", + "thiserror 2.0.18", "tree-sitter", "tree-sitter-go", "tree-sitter-java", @@ -2719,8 +2922,21 @@ dependencies = [ name = "tracevault-enterprise" version = "0.1.0" dependencies = [ + "aes-gcm", "async-trait", + "base64", + "chrono", + "ed25519-dalek", + "glob-match", + "hex", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "sha2", "tracevault-core", + "tracing", + "uuid", ] [[package]] @@ -2738,16 +2954,18 @@ dependencies = [ "git2", "glob-match", "hex", + "hmac", "http", - "rand", + "rand 0.8.5", "reqwest", "serde", "serde_json", "sha2", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "tower-http", + "tower_governor", "tracevault-core", "tracevault-enterprise", "tracing", @@ -3163,6 +3381,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3173,6 +3401,28 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/crates/tracevault-server/Cargo.toml b/crates/tracevault-server/Cargo.toml index f0e9953..f0cfffd 100644 --- a/crates/tracevault-server/Cargo.toml +++ b/crates/tracevault-server/Cargo.toml @@ -23,6 +23,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chro tower-http = { version = "0.6", features = ["cors", "trace"] } argon2 = "0.5" sha2 = "0.10" +hmac = "0.12" hex = "0.4" http = "1" glob-match = "0.2" @@ -35,6 +36,7 @@ async-trait = "0.1" aes-gcm = "0.10" dotenvy = "0.15.7" thiserror.workspace = true +tower_governor = "0.6" [dependencies.tracevault-enterprise] path = "../../enterprise" diff --git a/crates/tracevault-server/src/api/auth.rs b/crates/tracevault-server/src/api/auth.rs index 2a9d0c3..2f28190 100644 --- a/crates/tracevault-server/src/api/auth.rs +++ b/crates/tracevault-server/src/api/auth.rs @@ -7,6 +7,24 @@ use crate::auth::{generate_device_token, generate_session_token, hash_password, use crate::error::AppError; use crate::extractors::AuthUser; +/// Basic email format validation: local@domain.tld +fn is_valid_email(email: &str) -> bool { + let Some((local, domain)) = email.split_once('@') else { + return false; + }; + if local.is_empty() || domain.is_empty() { + return false; + } + if local.contains(' ') || domain.contains(' ') { + return false; + } + let parts: Vec<&str> = domain.split('.').collect(); + if parts.len() < 2 { + return false; + } + parts.iter().all(|p| !p.is_empty()) +} + // --- Register --- #[derive(Deserialize)] @@ -31,10 +49,12 @@ pub async fn register( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), AppError> { - if req.password.len() < 10 { - return Err(AppError::BadRequest( - "Password must be at least 10 characters".into(), - )); + if let Err(reason) = crate::password_policy::validate(&req.password) { + return Err(AppError::BadRequest(reason.into())); + } + + if !is_valid_email(&req.email) { + return Err(AppError::BadRequest("Invalid email format".into())); } let org_slug = req.org_name.trim().to_lowercase(); @@ -523,3 +543,28 @@ pub async fn request_invitation( Ok(StatusCode::CREATED) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_emails() { + assert!(is_valid_email("user@example.com")); + assert!(is_valid_email("user.name@example.co.uk")); + assert!(is_valid_email("user+tag@domain.org")); + assert!(is_valid_email("a@b.co")); + } + + #[test] + fn invalid_emails() { + assert!(!is_valid_email("")); + assert!(!is_valid_email("notanemail")); + assert!(!is_valid_email("@domain.com")); + assert!(!is_valid_email("user@")); + assert!(!is_valid_email("user@.com")); + assert!(!is_valid_email("user@domain")); + assert!(!is_valid_email("user @domain.com")); + assert!(!is_valid_email("user@domain .com")); + } +} diff --git a/crates/tracevault-server/src/api/github.rs b/crates/tracevault-server/src/api/github.rs index d1a77fb..9e6e25e 100644 --- a/crates/tracevault-server/src/api/github.rs +++ b/crates/tracevault-server/src/api/github.rs @@ -1,13 +1,36 @@ use axum::{ + body::Bytes, extract::State, http::{HeaderMap, StatusCode}, - Json, }; use chrono::Utc; +use hmac::{Hmac, Mac}; +use sha2::Sha256; use uuid::Uuid; use crate::AppState; +type HmacSha256 = Hmac; + +/// Verify the GitHub webhook signature (X-Hub-Signature-256 header). +/// Returns true if the signature is valid. +fn verify_webhook_signature(secret: &str, body: &[u8], signature_header: Option<&str>) -> bool { + let Some(header) = signature_header else { + return false; + }; + let Some(hex_sig) = header.strip_prefix("sha256=") else { + return false; + }; + let Ok(sig_bytes) = hex::decode(hex_sig) else { + return false; + }; + let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else { + return false; + }; + mac.update(body); + mac.verify_slice(&sig_bytes).is_ok() +} + /// POST /api/v1/github/webhook /// /// Handles GitHub webhook events: @@ -16,8 +39,20 @@ use crate::AppState; pub async fn webhook( State(state): State, headers: HeaderMap, - Json(body): Json, + body: Bytes, ) -> (StatusCode, &'static str) { + let signature = headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()); + + if !verify_webhook_signature(&state.github_webhook_secret, &body, signature) { + return (StatusCode::UNAUTHORIZED, "invalid webhook signature"); + } + + let Ok(body) = serde_json::from_slice::(&body) else { + return (StatusCode::BAD_REQUEST, "invalid JSON body"); + }; + let event_type = headers .get("x-github-event") .and_then(|v| v.to_str().ok()) @@ -126,3 +161,42 @@ async fn handle_create( (StatusCode::OK, "tag processed") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_signature_valid() { + let secret = "test-secret"; + let body = b"{\"action\":\"push\"}"; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let expected = hex::encode(mac.finalize().into_bytes()); + let header_value = format!("sha256={expected}"); + assert!(verify_webhook_signature(secret, body, Some(&header_value))); + } + + #[test] + fn verify_signature_invalid() { + assert!(!verify_webhook_signature( + "test-secret", + b"{}", + Some("sha256=deadbeef") + )); + } + + #[test] + fn verify_signature_missing() { + assert!(!verify_webhook_signature("test-secret", b"{}", None)); + } + + #[test] + fn verify_signature_wrong_prefix() { + assert!(!verify_webhook_signature( + "test-secret", + b"{}", + Some("sha1=deadbeef") + )); + } +} diff --git a/crates/tracevault-server/src/api/orgs.rs b/crates/tracevault-server/src/api/orgs.rs index 9136919..d9ac12e 100644 --- a/crates/tracevault-server/src/api/orgs.rs +++ b/crates/tracevault-server/src/api/orgs.rs @@ -290,6 +290,9 @@ pub async fn invite_member( let user_id = if let Some((id,)) = existing_user { id } else { + if let Err(reason) = crate::password_policy::validate(&req.password) { + return Err(AppError::BadRequest(reason.into())); + } let password_hash = hash_password(&req.password) .map_err(|e| AppError::Internal(format!("Failed to hash password: {e}")))?; diff --git a/crates/tracevault-server/src/config.rs b/crates/tracevault-server/src/config.rs index 7f8773a..8d4ac6e 100644 --- a/crates/tracevault-server/src/config.rs +++ b/crates/tracevault-server/src/config.rs @@ -4,9 +4,10 @@ pub struct ServerConfig { pub database_url: String, pub host: String, pub port: u16, - pub cors_origin: Option, + pub cors_origin: String, pub repos_dir: String, pub encryption_key: Option, + pub github_webhook_secret: String, } impl ServerConfig { @@ -20,9 +21,12 @@ impl ServerConfig { .ok() .and_then(|p| p.parse().ok()) .unwrap_or(3000), - cors_origin: env::var("CORS_ORIGIN").ok(), + cors_origin: env::var("CORS_ORIGIN") + .expect("CORS_ORIGIN environment variable is required"), repos_dir: env::var("TRACEVAULT_REPOS_DIR").unwrap_or_else(|_| "./data/repos".into()), encryption_key: env::var("TRACEVAULT_ENCRYPTION_KEY").ok(), + github_webhook_secret: env::var("GITHUB_WEBHOOK_SECRET") + .expect("GITHUB_WEBHOOK_SECRET environment variable is required"), } } @@ -41,10 +45,25 @@ mod tests { database_url: String::new(), host: "127.0.0.1".into(), port: 8080, - cors_origin: None, + cors_origin: "http://localhost:4000".into(), repos_dir: ".".into(), encryption_key: None, + github_webhook_secret: "test-secret".into(), }; assert_eq!(cfg.bind_addr(), "127.0.0.1:8080"); } + + #[test] + fn cors_origin_is_required() { + let cfg = ServerConfig { + database_url: String::new(), + host: "127.0.0.1".into(), + port: 8080, + cors_origin: "http://localhost:4000".into(), + repos_dir: ".".into(), + encryption_key: None, + github_webhook_secret: "test-secret".into(), + }; + assert_eq!(cfg.cors_origin, "http://localhost:4000"); + } } diff --git a/crates/tracevault-server/src/error.rs b/crates/tracevault-server/src/error.rs index e9d286e..5b0b6ce 100644 --- a/crates/tracevault-server/src/error.rs +++ b/crates/tracevault-server/src/error.rs @@ -41,8 +41,20 @@ impl IntoResponse for AppError { AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".into()), AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()), - AppError::Sqlx(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), - AppError::Git(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + AppError::Sqlx(e) => { + tracing::error!("Database error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".into(), + ) + } + AppError::Git(e) => { + tracing::error!("Git error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".into(), + ) + } }; (status, Json(json!({ "error": message }))).into_response() } @@ -110,4 +122,30 @@ mod tests { let resp = AppError::Internal("x".into()).into_response(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + + #[test] + fn sqlx_error_does_not_leak_details() { + let err = AppError::Sqlx(sqlx::Error::ColumnNotFound("password_hash".into())); + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = tokio::runtime::Runtime::new().unwrap().block_on(async { + let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + }); + assert!(!body.contains("password_hash")); + assert!(body.contains("Internal server error")); + } + + #[test] + fn git_error_does_not_leak_details() { + let err = AppError::Git(git2::Error::from_str("path /secret/repo not found")); + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = tokio::runtime::Runtime::new().unwrap().block_on(async { + let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + }); + assert!(!body.contains("/secret/repo")); + assert!(body.contains("Internal server error")); + } } diff --git a/crates/tracevault-server/src/lib.rs b/crates/tracevault-server/src/lib.rs index a40c7a1..cd93d88 100644 --- a/crates/tracevault-server/src/lib.rs +++ b/crates/tracevault-server/src/lib.rs @@ -10,6 +10,7 @@ pub mod extensions; pub mod extractors; pub mod llm; pub mod org_signing; +pub mod password_policy; pub mod permissions; pub mod pricing; pub mod pricing_sync; @@ -28,4 +29,5 @@ pub struct AppState { pub extensions: extensions::ExtensionRegistry, pub encryption_key: Option, pub http_client: reqwest::Client, + pub github_webhook_secret: String, } diff --git a/crates/tracevault-server/src/main.rs b/crates/tracevault-server/src/main.rs index 3407143..080da00 100644 --- a/crates/tracevault-server/src/main.rs +++ b/crates/tracevault-server/src/main.rs @@ -3,8 +3,9 @@ use axum::{ Router, }; use http::Method; -#[cfg(not(feature = "enterprise"))] +use std::net::SocketAddr; use std::sync::Arc; +use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; @@ -24,20 +25,32 @@ async fn main() { .await .expect("Failed to run migrations"); - let cors = if let Some(origin) = &cfg.cors_origin { - CorsLayer::new() - .allow_origin(origin.parse::().unwrap()) - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION]) - } else { - CorsLayer::permissive() - }; + let cors = CorsLayer::new() + .allow_origin( + cfg.cors_origin + .parse::() + .expect("CORS_ORIGIN must be a valid header value"), + ) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION]); + + let auth_rate_limit = GovernorConfigBuilder::default() + .per_second(6) + .burst_size(10) + .finish() + .expect("Failed to build auth rate limiter"); + + let public_rate_limit = GovernorConfigBuilder::default() + .per_second(1) + .burst_size(60) + .finish() + .expect("Failed to build public rate limiter"); let repo_manager = repo_manager::RepoManager::new(&cfg.repos_dir); let extensions = build_extensions(&cfg); @@ -103,10 +116,8 @@ async fn main() { let bind_addr = cfg.bind_addr(); - let app = Router::new() - .route("/health", get(|| async { "ok" })) - .route("/api/v1/features", get(api::features::get_features)) - // Auth (public) + // Auth routes (strict: 10 req/min per IP) + let auth_routes = Router::new() .route("/api/v1/auth/register", post(api::auth::register)) .route("/api/v1/auth/login", post(api::auth::login)) .route("/api/v1/auth/device", post(api::auth::device_start)) @@ -114,18 +125,32 @@ async fn main() { "/api/v1/auth/device/{token}/status", get(api::auth::device_status), ) + .layer(GovernorLayer { + config: Arc::new(auth_rate_limit), + }); + + // Public routes (moderate: 60 req/min per IP) + let public_routes = Router::new() + .route("/health", get(|| async { "ok" })) + .route("/api/v1/features", get(api::features::get_features)) + .route("/api/v1/orgs/public", get(api::auth::list_public_orgs)) + .route( + "/api/v1/invitation-requests", + post(api::auth::request_invitation), + ) + .route("/api/v1/github/webhook", post(api::github::webhook)) + .layer(GovernorLayer { + config: Arc::new(public_rate_limit), + }); + + // Authenticated routes (no rate limiting) + let authenticated_routes = Router::new() .route( "/api/v1/auth/device/{token}/approve", post(api::auth::device_approve), ) .route("/api/v1/auth/logout", post(api::auth::logout)) .route("/api/v1/auth/me", get(api::auth::me)) - // Public (no auth) — for invitation request form - .route("/api/v1/orgs/public", get(api::auth::list_public_orgs)) - .route( - "/api/v1/invitation-requests", - post(api::auth::request_invitation), - ) // User endpoints .route("/api/v1/me/orgs", get(api::auth::list_my_orgs)) // Org management (create is org-agnostic) @@ -392,9 +417,12 @@ async fn main() { .route( "/api/v1/orgs/{slug}/repos/{repo_id}/ci/verify", post(api::ci::verify_commits), - ) - // GitHub webhook (org-agnostic) - .route("/api/v1/github/webhook", post(api::github::webhook)) + ); + + let app = Router::new() + .merge(auth_routes) + .merge(public_routes) + .merge(authenticated_routes) .layer(TraceLayer::new_for_http()) .layer(cors) .with_state(AppState { @@ -403,11 +431,17 @@ async fn main() { extensions, encryption_key: cfg.encryption_key.clone(), http_client: http_client.clone(), + github_webhook_secret: cfg.github_webhook_secret.clone(), }); let listener = tokio::net::TcpListener::bind(&bind_addr).await.unwrap(); tracing::info!("TraceVault server listening on {}", bind_addr); - axum::serve(listener, app).await.unwrap(); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); } async fn sync_repos_on_startup( diff --git a/crates/tracevault-server/src/password_policy.rs b/crates/tracevault-server/src/password_policy.rs new file mode 100644 index 0000000..b3274c1 --- /dev/null +++ b/crates/tracevault-server/src/password_policy.rs @@ -0,0 +1,122 @@ +/// Common breached passwords (top entries from known breach databases). +/// Passwords on this list are rejected regardless of length. +const BREACHED_PASSWORDS: &[&str] = &[ + "123456789012", + "password1234", + "qwerty123456", + "iloveyou1234", + "admin1234567", + "letmein12345", + "welcome12345", + "monkey123456", + "dragon123456", + "master123456", + "qwertyuiopas", + "password1235", + "123456789abc", + "abc123456789", + "trustno1trust", + "changeme1234", + "football1234", + "baseball1234", + "shadow123456", + "michael12345", + "jennifer1234", + "superman1234", + "batman123456", + "whatever1234", + "passw0rd1234", + "p@ssword1234", + "password12345", + "1234567890ab", + "qazwsx123456", + "000000000000", + "111111111111", + "121212121212", + "aaaaaaaaaaaa", + "abcdefghijkl", + "abcdef123456", + "password!234", + "charlie12345", + "donald123456", + "loveme123456", + "sunshine1234", + "princess1234", + "starwars1234", + "computer1234", + "corvette1234", + "1qaz2wsx3edc", + "zaq12wsx3edc", + "asdfghjkl123", + "qwertyuiop12", + "1q2w3e4r5t6y", + "abcdefg12345", +]; + +const MIN_LENGTH: usize = 12; +const MAX_LENGTH: usize = 128; + +/// Validate a password against the NIST 800-63B-inspired policy. +/// Returns `Ok(())` if the password is acceptable, or `Err(reason)` if not. +pub fn validate(password: &str) -> Result<(), &'static str> { + if password.len() < MIN_LENGTH { + return Err("Password must be at least 12 characters"); + } + if password.len() > MAX_LENGTH { + return Err("Password must be at most 128 characters"); + } + let lower = password.to_lowercase(); + if BREACHED_PASSWORDS.contains(&lower.as_str()) { + return Err("This password is too common and has appeared in data breaches"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_short_passwords() { + assert!(validate("short").is_err()); + assert!(validate("11charssss1").is_err()); + } + + #[test] + fn accepts_minimum_length() { + assert!(validate("12characters").is_ok()); + assert!(validate("exactly12chr").is_ok()); + } + + #[test] + fn rejects_too_long() { + let long = "a".repeat(129); + assert!(validate(&long).is_err()); + } + + #[test] + fn accepts_max_length() { + let max = "a".repeat(128); + assert!(validate(&max).is_ok()); + } + + #[test] + fn rejects_breached_passwords() { + assert!(validate("password1234").is_err()); + assert!(validate("qwerty123456").is_err()); + assert!(validate("123456789012").is_err()); + } + + #[test] + fn breached_check_is_case_insensitive() { + assert!(validate("PASSWORD1234").is_err()); + assert!(validate("Password1234").is_err()); + } + + #[test] + fn accepts_good_passwords() { + assert!(validate("my-secure-passphrase-2026").is_ok()); + assert!(validate("correcthorsebatterystaple").is_ok()); + assert!(validate("xK9#mP2$vL5@qR8").is_ok()); + } +}