diff --git a/README.md b/README.md index d1587583..12da8175 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Open `http://127.0.0.1:8080/admin`. Useful environment variables: - `BIND_ADDR`: listen address, default `127.0.0.1:8080` -- `ADMIN_TOKEN`: optional write token for management writes via `X-Admin-Token` +- write-capable admin credential for management writes via `X-Admin-Token`: provide either `ADMIN_TOKEN`, a write-capable `ADMIN_TOKENS` principal, or `WAF_IDS_CREDENTIALS_PATH`. Optional only for numeric loopback binds (`127.0.0.0/8` or `::1`). Required before readiness on any other `BIND_ADDR` (`0.0.0.0`, `::`, LAN, public). See [docs/runbooks/operations.md](docs/runbooks/operations.md) and [docs/security/threat-model.md](docs/security/threat-model.md). - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero diff --git a/docs/deployment/production.md b/docs/deployment/production.md index 1c46ac73..7bac637b 100644 --- a/docs/deployment/production.md +++ b/docs/deployment/production.md @@ -59,7 +59,7 @@ Failure, recovery, verification, and evidence requirements are documented in [`. - Terminate TLS in front of the service. - Expose `/admin` and `/api/*` only through identity-aware access. - Configure upstream allowlists and egress policy. -- Store `ADMIN_TOKEN` in a secret manager. +- Store the write-capable administrator credential in a secret manager. The process will not become ready on any non-loopback `BIND_ADDR` if no usable `ADMIN_TOKEN`, write-capable `ADMIN_TOKENS` principal, or `WAF_IDS_CREDENTIALS_PATH` credential is configured. Recovery is to provision the secret authority and restart; do not disable the gate. This fail-closed bootstrap aligns with the threat model and the NIST guidance cited in [docs/security/threat-model.md](../security/threat-model.md). - Mount persistent state or replace JSON persistence with a database. - Run `scripts/smoke.sh` before promoting a release. - Keep block mode route-scoped and reversible. diff --git a/docs/doctoring/fail-closed-management-auth.md b/docs/doctoring/fail-closed-management-auth.md new file mode 100644 index 00000000..75550ac0 --- /dev/null +++ b/docs/doctoring/fail-closed-management-auth.md @@ -0,0 +1,41 @@ +# Doctoring — fail-closed management authentication + +This note grounds the issue #78 implementation: non-loopback listeners refuse to become ready without a write-capable administrator principal, authentication and authorization failures remain distinct, and presented administrator secrets are compared without early-exit content comparison. IEEE PDFs are not redistributed; freely accessible standards are cited by stable locators. + +## Adopted standards and literature + +Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in computer systems. *Proceedings of the IEEE, 63*(9), 1278–1308. https://doi.org/10.1109/PROC.1975.9939 + +- **Design impact:** Fail-safe defaults require missing access authority to deny rather than silently enable management writes. Wardnet therefore refuses readiness when a non-loopback listener lacks a write-capable administrator credential. + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard 5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ + +- **Design impact:** Administrative functions require authentication and authorization. Wardnet returns `401` for an unauthenticated management request and `403` when an authenticated readonly principal attempts a mutation, without disclosing the expected secret or role. + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +- **Design impact:** Authentication data is loaded through the credential bootstrap boundary rather than embedded in distributable assets. Health output exposes only non-secret configuration state such as authentication mode and credential source. + +MITRE. (n.d.). *CWE-306: Missing authentication for critical function*. https://cwe.mitre.org/data/definitions/306.html + +- **Design impact:** Management APIs that mutate routes, threat indicators, DNSBL entries, license state, or feeds are critical functions. The runtime gate rejects the unsafe combination of a reachable non-loopback listener and missing write-capable authentication. + +## Redistributable research artifact + +`docs/papers/nist-sp-800-218-ssdf.pdf` is the NIST SP 800-218 Version 1.1 PDF published by the National Institute of Standards and Technology. Authoritative source: https://doi.org/10.6028/NIST.SP.800-218 (NIST publication record and official PDF). NIST states that SP 800-series publications are not subject to copyright in the United States and that attribution is appreciated; NIST's Technical Series policy also grants a worldwide royalty-free right to reprint covered NIST works. The repository therefore retains the exact PDF as research evidence with this attribution: “Republished courtesy of the National Institute of Standards and Technology.” The publication remains authoritative at NIST; the repository copy is evidence only and does not supersede the official source. + +NIST SP 800-218 Rev. 1 / SSDF 1.2 is still an Initial Public Draft as of this doctoring update, so the implemented control continues to cite final SP 800-218 Version 1.1 rather than presenting the draft as a final standard. + +## Implementation binding + +| Decision | Implementation boundary | +| --- | --- | +| Fail closed on public bind | `require_write_auth_for_bind` before listener readiness | +| Loopback development remains usable | loopback-only listener detection and `/healthz.auth_mode=development` | +| Authentication vs authorization | management write rejection distinguishes `401` and `403` | +| Constant-time credential handling | administrator-token comparison uses a bounded constant-work comparison path | +| Blank credential path | an empty or whitespace credentials-path bootstrap value is treated as unset | +| Smoke-test credential | `scripts/smoke.sh` creates a per-process administrator token instead of shipping a repository credential | +| Ambiguous token registry | strict administrator-token parsing rejects duplicate, blank, or unknown-role entries | + +PII is not blanket-masked from security evidence when doing so would make incident response unusable. Purpose-bound authorization, least privilege, auditability, retention controls, and encryption are the preferred controls for operationally necessary security data. diff --git a/docs/papers/nist-sp-800-218-ssdf.pdf b/docs/papers/nist-sp-800-218-ssdf.pdf new file mode 100644 index 00000000..0158f4eb Binary files /dev/null and b/docs/papers/nist-sp-800-218-ssdf.pdf differ diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 8cf0a35b..4fc52235 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -16,13 +16,22 @@ - Operators use management APIs and the embedded admin console. - Upstream services are outside the process trust boundary. - The state file is trusted only after JSON deserialization succeeds. +- A non-loopback listener is untrusted until a write-capable admin principal exists in the credential registry. This follows the fail-secure and authenticator-management posture documented in the production guide and runbook: start closed, bootstrap secrets into the registry, then expose the listener only after a usable write credential exists. - Threat feed import payloads are untrusted operator-supplied data. +## Security Grounding + +The startup gate and secret-handling path in this PR are aligned with NIST guidance that authentication secrets need lifecycle control and protected handling, and that authenticators should fail securely instead of silently degrading to weaker access. Wardnet applies that by preferring `WAF_IDS_CREDENTIALS_PATH`, allowing env only as bootstrap transport, and refusing non-loopback readiness when no usable write credential can be presented through `X-Admin-Token`. The operator recovery path is documented in [docs/deployment/production.md](../deployment/production.md), and the accepted bootstrap sources and RBAC shapes are documented in [docs/runbooks/operations.md](../runbooks/operations.md). + +### Research artifact redistribution assessment + +The authentication-specific NIST SP 800-57 Part 1 Rev. 5 and NIST SP 800-63B sources below remain linked to their authoritative publication records and summarized here; this PR does not republish copies of those two PDFs because the exact retrieved artifacts were not independently assessed for redistribution during this change. Separately, the branch retains `docs/papers/nist-sp-800-218-ssdf.pdf` as redistributable NIST SP 800-218 Version 1.1 evidence for the secure-development and credential-bootstrap boundary. Its authoritative source, redistribution basis, attribution, and final-versus-draft status are recorded in [docs/doctoring/fail-closed-management-auth.md](../doctoring/fail-closed-management-auth.md). The repository copy is evidence only and does not supersede NIST's publication. + ## Primary Threats | Threat | Impact | Current Control | Required Hardening | | --- | --- | --- | --- | -| Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM | +| Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; fail-closed startup on non-loopback bind without a write-capable principal; `401` vs `403` without revealing the expected role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | | State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | | Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Upstream allowlists, egress policy | @@ -33,3 +42,11 @@ ## Human Approval Boundary AI SOC recommendations may explain, summarize, or suggest actions, but enforcement-changing decisions must remain human-approved until audit trails, rollback, and policy simulation are implemented. + +## References + +Barker, E. (2020). *Recommendation for key management: Part 1 - General* (NIST SP 800-57 Part 1 Rev. 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 + +Grassi, P. A., Garcia, M. E., & Fenton, J. L. (2020). *Digital identity guidelines: Authentication and lifecycle management* (NIST SP 800-63B). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63b + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 diff --git a/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs b/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs index 80fadda8..6f93c147 100644 --- a/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs +++ b/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs @@ -1,17 +1,71 @@ #![no_main] -//! Fuzz the admin-token config parser: `waf_ids_ai_soc::parse_admin_tokens`. +//! Fuzz the admin-token config parser: `waf_ids_ai_soc::parse_admin_tokens` +//! and the strict startup mirror. //! //! This parses the `ADMIN_TOKENS` operator config string //! (`token:actor[:role],...`) into an RBAC principal map. Malformed or //! adversarial config must never panic, and the parser's structural invariants //! must hold for every input: //! * no empty token key ever ends up in the map; -//! * every actor value is non-empty (defaults to "admin"). +//! * every actor value is non-empty (defaults to "admin"); +//! * the strict startup parser either rejects ambiguous input or yields the +//! same non-empty token/actor invariants; +//! * strict startup always rejects duplicate secrets, blank list entries, and +//! unknown roles, matching the stable proptest mirror; +//! * accepted write/readonly aliases preserve their authorization semantics. use libfuzzer_sys::fuzz_target; -use waf_ids_ai_soc::parse_admin_tokens; +use std::fmt::Write as _; +use waf_ids_ai_soc::{parse_admin_tokens, parse_admin_tokens_strict}; fuzz_target!(|data: &[u8]| { + // Derive a header-safe token from arbitrary bytes before UTF-8 decoding so + // every libFuzzer execution reaches the deterministic security invariants. + // The arbitrary parser path below remains limited to valid UTF-8 because + // ADMIN_TOKENS is a string-valued configuration contract. + let mut seed = String::from("fuzz"); + for byte in data.iter().take(8) { + write!(&mut seed, "{byte:02x}").expect("writing to String cannot fail"); + } + + let duplicate = format!("{seed}:alice,{seed}:bob"); + assert!( + parse_admin_tokens_strict(&duplicate).is_err(), + "strict startup must reject duplicate secrets" + ); + + let blank_entry = format!("{seed}:alice,,other:bob"); + assert!( + parse_admin_tokens_strict(&blank_entry).is_err(), + "strict startup must reject blank list entries" + ); + + let unknown_role = format!("{seed}:alice:not-a-role"); + assert!( + parse_admin_tokens_strict(&unknown_role).is_err(), + "strict startup must reject unknown roles" + ); + + let writer = format!("{seed}:alice:operator"); + let writer_tokens = + parse_admin_tokens_strict(&writer).expect("operator role must remain accepted"); + assert!( + writer_tokens + .get(&seed) + .is_some_and(|principal| principal.can_write), + "operator role must remain write-capable" + ); + + let reader = format!("{seed}:alice:readonly"); + let reader_tokens = + parse_admin_tokens_strict(&reader).expect("readonly role must remain accepted"); + assert!( + reader_tokens + .get(&seed) + .is_some_and(|principal| !principal.can_write), + "readonly role must remain non-writing" + ); + let Ok(raw) = std::str::from_utf8(data) else { return; }; @@ -24,4 +78,14 @@ fuzz_target!(|data: &[u8]| { "actor value must never be empty" ); } + + if let Ok(tokens) = parse_admin_tokens_strict(raw) { + for (token, principal) in &tokens { + assert!(!token.is_empty(), "strict token key must never be empty"); + assert!( + !principal.actor.is_empty(), + "strict actor value must never be empty" + ); + } + } }); diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 5df0c730..0cc18e0f 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -5,7 +5,11 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP_DIR="$(mktemp -d)" STATE_FILE="$TMP_DIR/state.json" LOG_FILE="$TMP_DIR/server.log" -ADMIN_TOKEN_VALUE="dev-secret" +ADMIN_TOKEN_VALUE="$(python3 - <<'PY' +import secrets +print(secrets.token_hex(16)) +PY +)" PORT="$(python3 - <<'PY' import socket s = socket.socket() @@ -27,10 +31,14 @@ cleanup() { trap cleanup EXIT start_server() { + # Compile before the health wait so rustc time is not counted as a hang. + cargo build --quiet --manifest-path "$ROOT_DIR/Cargo.toml" ( cd "$ROOT_DIR" BIND_ADDR="127.0.0.1:$PORT" \ ADMIN_TOKEN="$ADMIN_TOKEN_VALUE" \ + ADMIN_TOKENS= \ + WAF_IDS_CREDENTIALS_PATH= \ WAF_IDS_STATE_PATH="$STATE_FILE" \ DNSBL_ORIGIN="dnsbl.test" \ EVENT_LIMIT="5" \ @@ -77,6 +85,8 @@ assert_json_field "$health" 'data["status"] == "ok"' assert_json_field "$health" 'data["persistence"] == "file"' assert_json_field "$health" 'data["dnsbl_origin"] == "dnsbl.test"' assert_json_field "$health" 'data["event_limit"] == 5' +assert_json_field "$health" 'data["admin_auth_configured"] is True' +assert_json_field "$health" 'data["auth_mode"] == "production"' curl -fsS "$BASE_URL/admin" | grep -q "ContextualWisdomLab WAF/IDS/AI SOC Gateway" @@ -187,7 +197,7 @@ assert_json_field "$support_bundle" 'data["kpis"]["fresh_threat_feed_count"] == assert_json_field "$support_bundle" 'data["audit_log_count"] >= 3' assert_json_field "$support_bundle" 'data["threat_feed_freshness"][0]["stale"] is False' -audit_logs="$(curl -fsS "$BASE_URL/api/audit-logs")" +audit_logs="$(curl -fsS -H "x-admin-token: $ADMIN_TOKEN_VALUE" "$BASE_URL/api/audit-logs")" assert_json_field "$audit_logs" 'any(log["action"] == "upsert_route" and log["resource_id"] == "block" for log in data)' assert_json_field "$audit_logs" 'any(log["action"] == "update_commercial_license" and log["resource_id"] == "cwlab-enterprise" for log in data)' assert_json_field "$audit_logs" 'any(log["action"] == "import_threat_feed" and log["resource_id"] == "misp-seoul" for log in data)' @@ -212,7 +222,7 @@ license="$(curl -fsS "$BASE_URL/api/commercial/license")" assert_json_field "$license" 'data["license_status"] == "active"' feeds="$(curl -fsS "$BASE_URL/api/threat-feeds")" assert_json_field "$feeds" 'len(data) == 1' -audit_logs="$(curl -fsS "$BASE_URL/api/audit-logs")" +audit_logs="$(curl -fsS -H "x-admin-token: $ADMIN_TOKEN_VALUE" "$BASE_URL/api/audit-logs")" assert_json_field "$audit_logs" 'len(data) >= 3' echo "smoke ok: $BASE_URL with state $STATE_FILE" diff --git a/src/credentials.rs b/src/credentials.rs index bcc07e50..f43af18d 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -59,7 +59,7 @@ impl CredentialRegistry { pub fn has_admin_auth(&self) -> bool { self.get_credential(CRED_ADMIN_TOKEN) - .is_some_and(|v| !v.is_empty()) + .is_some_and(|v| !v.trim().is_empty()) || self .get_credential(CRED_ADMIN_TOKENS) .is_some_and(|v| !v.trim().is_empty()) @@ -83,25 +83,14 @@ impl CredentialRegistry { let mut admin_from_file = false; let mut admin_from_env = false; - if let Some(path) = credentials_path { + if let Some(path) = credentials_path.and_then(nonempty_credentials_path) { match std::fs::read_to_string(path) { Ok(content) => { - let file_map: HashMap = - serde_json::from_str(&content).map_err(|error| { - format!( - "credentials file {} is not valid JSON: {error}", - path.display() - ) - })?; - for key in [CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS] { - if let Some(raw) = file_map.get(key) { - let text = json_value_as_nonempty_string(raw); - if let Some(text) = text { - values.insert(key.to_string(), text); - admin_from_file = true; - } - } - } + let file_values = parse_credentials_json(&content).map_err(|error| { + format!("credentials file {} is invalid: {error}", path.display()) + })?; + admin_from_file = !file_values.is_empty(); + values.extend(file_values); } Err(error) if error.kind() == ErrorKind::NotFound => {} Err(error) => { @@ -114,17 +103,25 @@ impl CredentialRegistry { } if !values.contains_key(CRED_ADMIN_TOKEN) - && let Some(token) = env_admin_token.filter(|value| !value.is_empty()) + && let Some(token) = env_admin_token.filter(|value| !value.trim().is_empty()) { values.insert(CRED_ADMIN_TOKEN.to_string(), token); admin_from_env = true; } if !values.contains_key(CRED_ADMIN_TOKENS) - && let Some(tokens) = env_admin_tokens.filter(|value| !value.is_empty()) + && let Some(tokens) = env_admin_tokens.filter(|value| !value.trim().is_empty()) { values.insert(CRED_ADMIN_TOKENS.to_string(), tokens); admin_from_env = true; } + + if let Some(token) = values.get(CRED_ADMIN_TOKEN) { + validate_admin_header_secret(CRED_ADMIN_TOKEN, token)?; + } + if let Some(tokens) = values.get(CRED_ADMIN_TOKENS) { + validate_admin_token_list_header_secrets(tokens)?; + } + let source = if admin_from_file { CredentialSource::File } else if admin_from_env { @@ -137,24 +134,139 @@ impl CredentialRegistry { } } -fn json_value_as_nonempty_string(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()), - serde_json::Value::Null | serde_json::Value::String(_) => None, - other => { - let text = other.to_string(); - if text.is_empty() || text == "null" { - None - } else { - Some(text) - } +/// Parse the secret-bearing keys from credentials JSON. +/// +/// Missing keys may use bootstrap transport fallback, but an explicitly null or +/// blank key is invalid and cannot be silently replaced by another source. +fn parse_credentials_json(content: &str) -> Result, String> { + let file_map: HashMap = + serde_json::from_str(content).map_err(|error| format!("not valid JSON: {error}"))?; + let mut values = HashMap::new(); + for key in [CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS] { + if let Some(raw) = file_map.get(key) { + let text = match json_value_as_nonempty_string(raw) { + Ok(Some(text)) => text, + Ok(None) => return Err(format!("{key} must not be blank or null")), + Err(kind) => { + return Err(format!("{key} must be a non-empty JSON string, not {kind}")); + } + }; + values.insert(key.to_string(), text); } } + Ok(values) +} + +/// Reject secrets whose configured bytes cannot be presented unchanged in an +/// HTTP header. Normalizing a secret at the transport boundary would make the +/// startup credential differ from the value a caller can actually authenticate. +fn validate_admin_header_secret(name: &str, secret: &str) -> Result<(), String> { + if secret.is_empty() || secret != secret.trim() { + return Err(format!( + "{name} must not contain leading or trailing whitespace" + )); + } + if !secret.bytes().all(|byte| (0x20..=0x7e).contains(&byte)) { + return Err(format!( + "{name} must contain only visible ASCII header characters" + )); + } + Ok(()) +} + +/// Validate the secret field of each structured `ADMIN_TOKENS` item without +/// rejecting ordinary whitespace used between comma-separated items. The +/// strict role/parser layer remains responsible for item shape and role names. +fn validate_admin_token_list_header_secrets(raw: &str) -> Result<(), String> { + for item in raw.split(',') { + let item = item.trim(); + if item.is_empty() { + continue; + } + let secret = item.split(':').next().unwrap_or_default(); + validate_admin_header_secret(CRED_ADMIN_TOKENS, secret)?; + } + Ok(()) +} + +/// Return a credentials path only when it contains a non-whitespace path value. +fn nonempty_credentials_path(path: &Path) -> Option<&Path> { + if path.as_os_str().to_string_lossy().trim().is_empty() { + None + } else { + Some(path) + } +} + +/// Constant-time equality for presented admin secrets. +pub fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let max = left.len().max(right.len()); + let mut diff = u8::from(left.len() != right.len()); + for i in 0..max { + let l = left.get(i).copied().unwrap_or(0); + let r = right.get(i).copied().unwrap_or(0); + diff |= l ^ r; + } + diff == 0 +} + +/// True when `bind_addr` is a numeric loopback-only listener. +pub fn listen_is_loopback_only(bind_addr: &str) -> bool { + let trimmed = bind_addr.trim(); + if trimmed.is_empty() { + return false; + } + if let Ok(addr) = trimmed.parse::() { + return addr.ip().is_loopback(); + } + let Some(host) = bind_host(trimmed) else { + return false; + }; + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +fn bind_host(bind_addr: &str) -> Option<&str> { + if let Some(rest) = bind_addr.strip_prefix('[') { + let end = rest.find(']')?; + return Some(&rest[..end]); + } + bind_addr.rsplit_once(':').map(|(host, _)| host) +} + +/// Fail closed before readiness when a non-loopback listener has no +/// write-capable admin principal. +pub fn require_write_auth_for_bind( + bind_addr: &str, + has_write_capable_admin: bool, +) -> Result<(), String> { + if has_write_capable_admin || listen_is_loopback_only(bind_addr) { + Ok(()) + } else { + Err(format!( + "refusing to bind {bind_addr} without a write-capable admin credential: set ADMIN_TOKEN, ADMIN_TOKENS, or WAF_IDS_CREDENTIALS_PATH before listening on a non-loopback address" + )) + } +} + +fn json_value_as_nonempty_string( + value: &serde_json::Value, +) -> Result, &'static str> { + match value { + serde_json::Value::String(text) if !text.trim().is_empty() => Ok(Some(text.clone())), + serde_json::Value::Null | serde_json::Value::String(_) => Ok(None), + serde_json::Value::Bool(_) => Err("a boolean"), + serde_json::Value::Number(_) => Err("a number"), + serde_json::Value::Array(_) => Err("an array"), + serde_json::Value::Object(_) => Err("an object"), + } } #[cfg(test)] mod tests { use super::*; + use proptest::prelude::*; use std::io::Write; #[test] @@ -182,6 +294,53 @@ mod tests { assert!(!registry.has_admin_auth()); } + #[test] + fn whitespace_env_admin_token_does_not_authorize_public_bind() { + let registry = + CredentialRegistry::bootstrap_secrets(None, Some(" \t".to_string()), None).unwrap(); + assert_eq!(registry.source(), CredentialSource::None); + assert_eq!(registry.get_credential(CRED_ADMIN_TOKEN), None); + assert!(require_write_auth_for_bind("0.0.0.0:0", false).is_err()); + } + + #[test] + fn boundary_whitespace_admin_token_is_rejected() { + for value in [" secret", "secret ", "\tsecret", "secret\t"] { + let error = CredentialRegistry::bootstrap_secrets(None, Some(value.to_string()), None) + .unwrap_err(); + assert!(error.contains("admin_token"), "{error}"); + } + } + + #[test] + fn non_visible_admin_token_is_rejected() { + for value in ["sec\tret", "sec\u{7f}ret", "sec\u{00e9}ret"] { + let error = CredentialRegistry::bootstrap_secrets(None, Some(value.to_string()), None) + .unwrap_err(); + assert!(error.contains("visible ASCII"), "{error}"); + } + } + + #[test] + fn admin_token_list_allows_separator_spacing_but_rejects_ambiguous_secret_bytes() { + CredentialRegistry::bootstrap_secrets( + None, + None, + Some("token-a:operator, token-b:readonly".to_string()), + ) + .unwrap(); + + for value in [ + "token-a :operator", + "token\tb:readonly", + "token\u{7f}:operator", + ] { + let error = CredentialRegistry::bootstrap_secrets(None, None, Some(value.to_string())) + .unwrap_err(); + assert!(error.contains("admin_tokens"), "{error}"); + } + } + #[test] fn file_overrides_env_per_key() { let dir = std::env::temp_dir().join(format!( @@ -288,4 +447,125 @@ mod tests { assert!(err.contains("not valid JSON")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn whitespace_file_admin_token_cannot_fall_back_to_env() { + let dir = std::env::temp_dir().join(format!( + "wardnet-creds-whitespace-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("credentials.json"); + std::fs::write(&path, r#"{"admin_token":" \t"}"#).unwrap(); + + let error = CredentialRegistry::bootstrap_secrets( + Some(&path), + Some("must-not-replace-file-value".to_string()), + None, + ) + .unwrap_err(); + assert!(error.contains("admin_token must not be blank or null")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explicit_null_file_admin_token_is_invalid() { + assert!(parse_credentials_json(r#"{"admin_token":null}"#).is_err()); + } + + #[test] + fn non_string_file_admin_token_is_invalid() { + for value in ["42", "true", "[]", "{}"] { + let error = + parse_credentials_json(&format!(r#"{{"admin_token":{value}}}"#)).unwrap_err(); + assert!(error.contains("non-empty JSON string"), "{error}"); + } + } + + #[test] + fn nonempty_credentials_path_filters_blank_values() { + assert!(nonempty_credentials_path(Path::new("")).is_none()); + assert!(nonempty_credentials_path(Path::new(" ")).is_none()); + let path = Path::new("credentials.json"); + assert_eq!(nonempty_credentials_path(path), Some(path)); + } + + #[test] + fn constant_time_eq_matches_equal_secrets_and_rejects_others() { + assert!(constant_time_eq(b"secret", b"secret")); + assert!(!constant_time_eq(b"secret", b"secreT")); + assert!(!constant_time_eq(b"secret", b"secret!")); + assert!(!constant_time_eq(b"secret", b"")); + assert!(constant_time_eq(b"", b"")); + } + + #[test] + fn constant_time_eq_rejects_lengths_differing_by_256_with_zero_suffix() { + let short = vec![0_u8; 1]; + let long = vec![0_u8; 257]; + assert!(!constant_time_eq(&short, &long)); + } + + #[test] + fn listen_is_loopback_only_classifies_bind_addresses() { + assert!(listen_is_loopback_only("127.0.0.1:0")); + assert!(listen_is_loopback_only("127.0.0.1:8080")); + assert!(listen_is_loopback_only("[::1]:8080")); + assert!(!listen_is_loopback_only("localhost:8080")); + assert!(!listen_is_loopback_only("LOCALHOST:9")); + assert!(!listen_is_loopback_only("0.0.0.0:0")); + assert!(!listen_is_loopback_only("0.0.0.0:8080")); + assert!(!listen_is_loopback_only("[::]:8080")); + assert!(!listen_is_loopback_only("192.0.2.10:8080")); + assert!(!listen_is_loopback_only("")); + assert!(!listen_is_loopback_only("not-an-address")); + } + + #[test] + fn require_write_auth_for_bind_fail_closes_public_listeners() { + require_write_auth_for_bind("127.0.0.1:0", false).unwrap(); + require_write_auth_for_bind("0.0.0.0:0", true).unwrap(); + let err = require_write_auth_for_bind("0.0.0.0:0", false).unwrap_err(); + assert!(err.contains("refusing to bind 0.0.0.0:0"), "{err}"); + assert!(err.contains("ADMIN_TOKEN"), "{err}"); + let err = require_write_auth_for_bind("[::]:8080", false).unwrap_err(); + assert!(err.contains("refusing to bind [::]:8080"), "{err}"); + } + + proptest! { + #[test] + fn credentials_json_rejects_non_string_admin_values( + value in prop_oneof![ + any::().prop_map(serde_json::Value::Bool), + any::().prop_map(|n| serde_json::Value::Number(n.into())), + proptest::collection::vec(any::(), 0..4).prop_map(|items| { + serde_json::Value::Array(items.into_iter().map(serde_json::Value::Bool).collect()) + }), + ] + ) { + let mut payload = serde_json::Map::new(); + payload.insert(CRED_ADMIN_TOKEN.to_string(), value); + let error = parse_credentials_json(&serde_json::Value::Object(payload).to_string()).unwrap_err(); + prop_assert!(error.contains("non-empty JSON string"), "{error}"); + } + + #[test] + fn bootstrap_rejects_boundary_whitespace_around_header_safe_admin_tokens( + token in "[!-~]{1,32}", + leading in any::(), + ) { + let configured = if leading { + format!(" {token}") + } else { + format!("{token} ") + }; + let error = CredentialRegistry::bootstrap_secrets(None, Some(configured), None).unwrap_err(); + prop_assert!(error.contains("leading or trailing whitespace"), "{error}"); + } + } } diff --git a/src/lib.rs b/src/lib.rs index ab902cae..38c1559f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ use axum::{ Json, Router, body::Bytes, extract::{DefaultBodyLimit, Path as PathParam, Query, State}, - http::{HeaderMap, Method, StatusCode, Uri}, + http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}, response::{Html, IntoResponse, Response}, routing::{any, get, post}, }; @@ -45,6 +45,7 @@ mod stix_import; mod suricata_eve; mod taxii; pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +pub use credentials::{listen_is_loopback_only, require_write_auth_for_bind}; #[derive(Clone)] pub struct AppState { @@ -58,6 +59,8 @@ pub struct AppState { admin_tokens: HashMap, /// Where admin secrets were bootstrapped from (file/env/none). Never holds values. credentials_source: CredentialSource, + /// True when the process listener is numeric loopback-only. + listen_loopback: bool, state_path: Option, dnsbl_origin: String, event_limit: usize, @@ -132,6 +135,7 @@ impl AppState { admin_token: config.admin_token, admin_tokens: HashMap::new(), credentials_source: CredentialSource::None, + listen_loopback: true, state_path: config.state_path, dnsbl_origin: normalized_origin(&config.dnsbl_origin), event_limit: config.event_limit.max(1), @@ -209,12 +213,19 @@ impl AppState { self } + /// Record whether the process listener is loopback-only. Builder-style. + pub fn with_listen_loopback(mut self, listen_loopback: bool) -> Self { + self.listen_loopback = listen_loopback; + self + } + + fn has_write_capable_admin(&self) -> bool { + has_write_admin_credential(self) + } + /// The principal mapped to the request's `X-Admin-Token`, if configured. fn principal_for_token(&self, headers: &HeaderMap) -> Option<&AdminPrincipal> { - headers - .get("x-admin-token") - .and_then(|value| value.to_str().ok()) - .and_then(|token| self.admin_tokens.get(token)) + presented_admin_token(headers).and_then(|token| matching_rbac_principal(self, token)) } /// The actor name mapped to the request's `X-Admin-Token`, if that token is a @@ -283,6 +294,11 @@ impl AppState { event_limit: self.event_limit, credentials_source: self.credentials_source.as_str().to_string(), admin_auth_configured: self.admin_token.is_some() || !self.admin_tokens.is_empty(), + auth_mode: if self.listen_loopback && !self.has_write_capable_admin() { + "development".to_string() + } else { + "production".to_string() + }, } } } @@ -402,8 +418,11 @@ pub struct HealthStatus { pub event_limit: usize, /// Bootstrap origin for admin secrets: `file`, `env`, or `none` (never secret values). pub credentials_source: String, - /// True when at least one admin write token is configured. + /// True when at least one admin credential is configured. pub admin_auth_configured: bool, + /// `development` only when the listener is loopback-only and no + /// write-capable principal is configured. + pub auth_mode: String, } const PHISHING_DATABASE_DEFAULT_FEED_ID: &str = "phishing-database-active"; @@ -620,8 +639,8 @@ async fn clearfolio_submit( PathParam(kind): PathParam, headers: HeaderMap, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let Some(config) = state.clearfolio.clone() else { return error( @@ -667,8 +686,8 @@ async fn clearfolio_status( PathParam(job_id): PathParam, headers: HeaderMap, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let Some(config) = state.clearfolio.clone() else { return error( @@ -810,8 +829,8 @@ async fn soc_analyze( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let Some(config) = state.soc_llm.clone() else { return error( @@ -918,8 +937,8 @@ async fn create_route( headers: HeaderMap, Json(route): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_route(&route) { return error(StatusCode::BAD_REQUEST, message); @@ -948,8 +967,8 @@ async fn create_threat( headers: HeaderMap, Json(indicator): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_threat(&indicator) { return error(StatusCode::BAD_REQUEST, message); @@ -985,8 +1004,8 @@ async fn create_dnsbl( headers: HeaderMap, Json(entry): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_dnsbl(&entry) { return error(StatusCode::BAD_REQUEST, message); @@ -1124,8 +1143,8 @@ async fn update_commercial_license( headers: HeaderMap, Json(profile): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_commercial_profile(&profile) { return error(StatusCode::BAD_REQUEST, message); @@ -1180,8 +1199,8 @@ async fn import_threat_feed( headers: HeaderMap, Json(feed): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_threat_feed_import(&feed) { return error(StatusCode::BAD_REQUEST, message); @@ -1232,8 +1251,8 @@ async fn import_stix_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1323,8 +1342,8 @@ async fn import_misp_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1414,8 +1433,8 @@ async fn import_opencti_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1526,8 +1545,8 @@ async fn poll_taxii_collection( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if request.feed_id.trim().is_empty() || request.source.trim().is_empty() { return error( @@ -1710,8 +1729,8 @@ async fn import_suricata_eve( headers: HeaderMap, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let body_text = match std::str::from_utf8(&body) { Ok(text) => text, @@ -1813,8 +1832,8 @@ async fn import_coraza_audit( headers: HeaderMap, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let body_text = match std::str::from_utf8(&body) { Ok(text) => text, @@ -1979,8 +1998,8 @@ async fn import_phishing_database_feed( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_phishing_database_import_request(&request) { return error(StatusCode::BAD_REQUEST, message); @@ -2112,8 +2131,8 @@ async fn import_kev_feed( "KEV import requires a configured write-capable admin credential", ); } - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_kev_import_request(&request) { return error(StatusCode::BAD_REQUEST, message); @@ -2559,54 +2578,86 @@ pub struct AdminPrincipal { pub can_write: bool, } +fn presented_admin_token(headers: &HeaderMap) -> Option<&str> { + headers + .get("x-admin-token") + .and_then(|value| value.to_str().ok()) +} + +/// Startup should only trust secrets that can be presented back through the +/// `X-Admin-Token` header without wire-format rejection. +fn admin_secret_supports_header_auth(token: &str) -> bool { + !token.trim().is_empty() && HeaderValue::from_str(token).is_ok() +} + +/// Scan every configured RBAC secret with constant-time comparison so a miss +/// does not reveal which slot matched. +fn matching_rbac_principal<'a>(state: &'a AppState, presented: &str) -> Option<&'a AdminPrincipal> { + let mut found = None; + for (token, principal) in &state.admin_tokens { + if credentials::constant_time_eq(token.as_bytes(), presented.as_bytes()) { + found = Some(principal); + } + } + found +} + /// True when the request presents a valid admin credential (write or readonly). -/// When no admin credentials are configured, returns true (auth disabled). +/// When no admin credentials are configured, loopback development remains open. fn admin_authenticated(state: &AppState, headers: &HeaderMap) -> bool { - let presented = headers - .get("x-admin-token") - .and_then(|value| value.to_str().ok()); if !state.admin_tokens.is_empty() { - return presented.is_some_and(|token| state.admin_tokens.contains_key(token)); + return presented_admin_token(headers) + .is_some_and(|token| matching_rbac_principal(state, token).is_some()); } let Some(expected) = state.admin_token.as_deref() else { return true; }; - presented.is_some_and(|actual| actual == expected) + presented_admin_token(headers) + .is_some_and(|actual| credentials::constant_time_eq(expected.as_bytes(), actual.as_bytes())) } /// True when the request may perform management **writes**. /// Readonly RBAC tokens authenticate but cannot write. fn admin_authorized(state: &AppState, headers: &HeaderMap) -> bool { - let presented = headers - .get("x-admin-token") - .and_then(|value| value.to_str().ok()); - // RBAC tokens take precedence when configured. if !state.admin_tokens.is_empty() { - return presented.is_some_and(|token| { - state - .admin_tokens - .get(token) - .is_some_and(|principal| principal.can_write) + return presented_admin_token(headers).is_some_and(|token| { + matching_rbac_principal(state, token).is_some_and(|principal| principal.can_write) }); } - // Fallback: single shared token (None means auth is disabled). let Some(expected) = state.admin_token.as_deref() else { return true; }; - presented.is_some_and(|actual| actual == expected) + presented_admin_token(headers) + .is_some_and(|actual| credentials::constant_time_eq(expected.as_bytes(), actual.as_bytes())) +} + +/// `401` when the caller is not authenticated; `403` when authenticated but not +/// permitted to write. The body stays stable so the expected role is not leaked. +fn reject_management_write(state: &AppState, headers: &HeaderMap) -> Option { + if admin_authorized(state, headers) { + return None; + } + let (status, message) = if admin_authenticated(state, headers) { + ( + StatusCode::FORBIDDEN, + "X-Admin-Token is not authorized for management writes", + ) + } else { + (StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token") + }; + Some(error(status, message)) } fn has_write_admin_credential(state: &AppState) -> bool { if !state.admin_tokens.is_empty() { - return state - .admin_tokens - .values() - .any(|principal| principal.can_write); + return state.admin_tokens.iter().any(|(token, principal)| { + principal.can_write && admin_secret_supports_header_auth(token) + }); } state .admin_token .as_deref() - .is_some_and(|token| !token.is_empty()) + .is_some_and(admin_secret_supports_header_auth) } fn audit_actor(state: &AppState, headers: &HeaderMap) -> String { @@ -2668,6 +2719,53 @@ pub fn parse_admin_tokens(raw: &str) -> HashMap { .collect() } +/// Startup parser for `ADMIN_TOKENS`. Rejects blank entries, blank tokens, +/// duplicate secrets, and unknown roles so ambiguous auth cannot become ready. +pub fn parse_admin_tokens_strict(raw: &str) -> Result, String> { + let mut map = HashMap::new(); + for item in raw.split(',') { + let item = item.trim(); + if item.is_empty() { + return Err( + "ADMIN_TOKENS contains a blank entry; remove repeated, leading, or trailing commas" + .to_string(), + ); + } + let mut parts = item.splitn(3, ':').map(str::trim); + let token = parts.next().unwrap_or(""); + if token.is_empty() { + return Err( + "ADMIN_TOKENS contains a blank token; remove the empty entry or supply a secret" + .to_string(), + ); + } + if map.contains_key(token) { + return Err( + "ADMIN_TOKENS contains a duplicate token; each secret must map to one principal" + .to_string(), + ); + } + let actor_raw = parts.next().unwrap_or(""); + let role_raw = parts.next().unwrap_or(""); + let actor = if actor_raw.is_empty() { + "admin".to_string() + } else { + actor_raw.to_string() + }; + let can_write = match role_raw.to_ascii_lowercase().as_str() { + "" | "admin" | "write" | "writer" | "operator" => true, + "readonly" | "read" | "reader" | "ro" => false, + other => { + return Err(format!( + "ADMIN_TOKENS role {other:?} is not recognised; use admin, write, or readonly" + )); + } + }; + map.insert(token.to_string(), AdminPrincipal { actor, can_write }); + } + Ok(map) +} + fn record_successful_audit_log( data: &mut AppData, actor: String, @@ -3277,6 +3375,7 @@ pub async fn run_from_env( shutdown: std::pin::Pin + Send>>, ) -> Result<(), Box> { let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); + let listen_loopback = listen_is_loopback_only(&bind_addr); // Secret-bearing values go through the credential registry (env/file are // bootstrap transports only). Operational config remains env for now. let credentials_path = std::env::var("WAF_IDS_CREDENTIALS_PATH") @@ -3291,7 +3390,10 @@ pub async fn run_from_env( admin_token: credentials .get_credential(CRED_ADMIN_TOKEN) .map(str::to_owned), - state_path: std::env::var("WAF_IDS_STATE_PATH").ok().map(PathBuf::from), + state_path: std::env::var("WAF_IDS_STATE_PATH") + .ok() + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from), dnsbl_origin: std::env::var("DNSBL_ORIGIN") .unwrap_or_else(|_| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()), event_limit: parse_event_limit(std::env::var("EVENT_LIMIT").ok().as_deref())?, @@ -3302,11 +3404,20 @@ pub async fn run_from_env( std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), 60, )?; - let admin_tokens = parse_admin_tokens( + let admin_tokens = match credentials.get_credential(CRED_ADMIN_TOKENS) { + Some(raw) if !raw.trim().is_empty() => parse_admin_tokens_strict(raw)?, + _ => HashMap::new(), + }; + let has_write_capable_admin = if !admin_tokens.is_empty() { + admin_tokens.iter().any(|(token, principal)| { + principal.can_write && admin_secret_supports_header_auth(token) + }) + } else { credentials - .get_credential(CRED_ADMIN_TOKENS) - .unwrap_or_default(), - ); + .get_credential(CRED_ADMIN_TOKEN) + .is_some_and(admin_secret_supports_header_auth) + }; + require_write_auth_for_bind(&bind_addr, has_write_capable_admin)?; let max_body_bytes = parse_u64_env( "MAX_BODY_BYTES", std::env::var("MAX_BODY_BYTES").ok().as_deref(), @@ -3314,17 +3425,23 @@ pub async fn run_from_env( )? as usize; let listener = tokio::net::TcpListener::bind(&bind_addr).await?; let local_addr = listener.local_addr()?; - println!("waf-ids-ai-soc listening on http://{local_addr}"); - // Flush so a supervising parent process (the e2e test) sees the readiness - // line immediately even though stdout is block-buffered when piped. - std::io::Write::flush(&mut std::io::stdout())?; + let auth_mode = if listen_loopback && !has_write_capable_admin { + "development" + } else { + "production" + }; let state = AppState::load(config) .await .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? .with_rate_limit(rate_limit, rate_limit_window) .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) + .with_listen_loopback(listen_loopback) .with_max_body_size(max_body_bytes); + println!("waf-ids-ai-soc listening on http://{local_addr} auth_mode={auth_mode}"); + // Flush so a supervising parent process (the e2e test) sees the readiness + // line immediately even though stdout is block-buffered when piped. + std::io::Write::flush(&mut std::io::stdout())?; let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown) .await; @@ -3432,6 +3549,54 @@ mod tests { clear_run_env(); } + #[tokio::test] + async fn run_from_env_fail_closes_non_loopback_without_admin() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + } + let err = run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("refusing to bind 0.0.0.0:0"), "{err}"); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_allows_non_loopback_when_admin_token_is_set() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + std::env::set_var("ADMIN_TOKEN", "startup-secret"); + } + run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap(); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_fail_closes_non_loopback_with_unpresentable_admin_token() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + std::env::set_var("ADMIN_TOKEN", "line\nbreak"); + } + let err = run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("admin_token must contain only visible ASCII header characters"), + "{err}" + ); + clear_run_env(); + } + #[tokio::test] async fn run_from_env_defaults_bind_addr_when_unset() { let _guard = ENV_GUARD.lock().await; @@ -3500,6 +3665,22 @@ mod tests { std::fs::remove_file(&path).ok(); } + #[tokio::test] + async fn run_from_env_fail_closes_when_admin_tokens_are_readonly_only() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + std::env::set_var("ADMIN_TOKENS", "tokR:reader:readonly"); + } + let err = run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("refusing to bind 0.0.0.0:0"), "{err}"); + clear_run_env(); + } + #[tokio::test] async fn run_from_env_ignores_kev_catalog_url_env_override() { let _guard = ENV_GUARD.lock().await; @@ -3778,6 +3959,33 @@ mod tests { assert_eq!(audit_actor(&state, &named), "carol"); } + #[test] + fn reject_management_write_distinguishes_authentication_from_authorization() { + let state = AppState::seeded(None) + .with_admin_tokens(parse_admin_tokens("write:ops:admin,read:auditor:readonly")); + + let unauthenticated = reject_management_write(&state, &HeaderMap::new()).unwrap(); + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let mut readonly = HeaderMap::new(); + readonly.insert("x-admin-token", "read".parse().unwrap()); + let unauthorized = reject_management_write(&state, &readonly).unwrap(); + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let body = runtime.block_on(body_text(unauthorized)); + assert!( + body.contains("not authorized for management writes"), + "{body}" + ); + + let mut writer = HeaderMap::new(); + writer.insert("x-admin-token", "write".parse().unwrap()); + assert!(reject_management_write(&state, &writer).is_none()); + } + #[tokio::test] async fn readonly_token_can_read_audit_logs_but_cannot_write() { let tokens = parse_admin_tokens("write:ops:admin,read:auditor:readonly"); @@ -3799,7 +4007,7 @@ mod tests { ), ) .await; - assert_eq!(denied.status(), StatusCode::UNAUTHORIZED); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); let created = app_request( &app, @@ -7635,6 +7843,7 @@ mod tests { event_limit: 25, credentials_source: "none".to_string(), admin_auth_configured: false, + auth_mode: "development".to_string(), } ); @@ -7645,6 +7854,41 @@ mod tests { let health = authed.health_status(); assert_eq!(health.credentials_source, "file"); assert!(health.admin_auth_configured); + assert_eq!(health.auth_mode, "production"); + + let unpresentable = state + .clone() + .with_admin_tokens(parse_admin_tokens("bad\nwrite:ops:admin")) + .with_credentials_source(CredentialSource::Env); + let health = unpresentable.health_status(); + assert_eq!(health.credentials_source, "env"); + assert!(health.admin_auth_configured); + assert_eq!(health.auth_mode, "development"); + } + + #[test] + fn parse_admin_tokens_strict_rejects_ambiguous_bootstrap_entries() { + let map = parse_admin_tokens_strict("tokA:alice,tokR:reader:readonly").unwrap(); + assert!(map.get("tokA").is_some_and(|p| p.can_write)); + assert!(map.get("tokR").is_some_and(|p| !p.can_write)); + let dup = parse_admin_tokens_strict("tokA:alice,tokA:bob").unwrap_err(); + assert!(dup.contains("duplicate token"), "{dup}"); + let role = parse_admin_tokens_strict("tokA:alice:superuser").unwrap_err(); + assert!(role.contains("not recognised"), "{role}"); + let blank = parse_admin_tokens_strict(":noname").unwrap_err(); + assert!(blank.contains("blank token"), "{blank}"); + let blank_entry = parse_admin_tokens_strict("tokA:alice,,tokB:bob").unwrap_err(); + assert!(blank_entry.contains("blank entry"), "{blank_entry}"); + } + + #[test] + fn has_write_admin_credential_ignores_unpresentable_tokens() { + let state = AppState::seeded(Some("line\nbreak".to_string())); + assert!(!has_write_admin_credential(&state)); + + let rbac_state = AppState::seeded(None) + .with_admin_tokens(parse_admin_tokens("good:ops:readonly,bad\nwrite:ops:admin")); + assert!(!has_write_admin_credential(&rbac_state)); } fn clearfolio_test_config(base_url: &str) -> ClearfolioConfig { diff --git a/tests/admin_auth_properties.rs b/tests/admin_auth_properties.rs new file mode 100644 index 00000000..cfdae9b0 --- /dev/null +++ b/tests/admin_auth_properties.rs @@ -0,0 +1,133 @@ +//! Stable property coverage for Wardnet's administrator credential boundary. +//! +//! These tests complement the parser libFuzzer target with the two behaviors +//! that only the application boundary can prove: credential-file values remain +//! strict JSON strings, and a header-authenticated principal reaches the same +//! write authorization encoded by `ADMIN_TOKENS`. + +use axum::{ + body::Body, + http::{Method, Request, StatusCode, header::CONTENT_TYPE}, +}; +use proptest::prelude::*; +use serde_json::Value; +use std::sync::atomic::{AtomicU64, Ordering}; +use tower::ServiceExt; +use waf_ids_ai_soc::{ + AppState, CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, build_app, + parse_admin_tokens_strict, +}; + +static NEXT_CREDENTIAL_FILE_ID: AtomicU64 = AtomicU64::new(1); + +fn bootstrap_from_json_value(key: &str, value: Value) -> Result { + let mut payload = serde_json::Map::new(); + payload.insert(key.to_string(), value); + + let id = NEXT_CREDENTIAL_FILE_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "wardnet-admin-auth-property-{}-{id}.json", + std::process::id() + )); + std::fs::write(&path, Value::Object(payload).to_string()) + .expect("property fixture must be writable"); + let result = CredentialRegistry::bootstrap_secrets(Some(&path), None, None); + let _ = std::fs::remove_file(path); + result +} + +async fn create_route_status(app: axum::Router, presented_token: Option<&str>) -> StatusCode { + let body = serde_json::json!({ + "id": "auth-property-route", + "path_prefix": "/auth-property", + "upstream": "mock://auth-property", + "mode": "monitor", + "enabled": true, + "block_threshold": null + }) + .to_string(); + + let mut builder = Request::builder() + .method(Method::POST) + .uri("/api/routes") + .header(CONTENT_TYPE, "application/json"); + if let Some(token) = presented_token { + builder = builder.header("X-Admin-Token", token); + } + + app.oneshot(builder.body(Body::from(body)).expect("valid request")) + .await + .expect("router must answer") + .status() +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + #[test] + fn credentials_file_rejects_null_whitespace_and_non_string_admin_values( + key_is_list in any::(), + value in prop_oneof![ + Just(Value::Null), + "[ \t]{1,8}".prop_map(Value::String), + any::().prop_map(Value::Bool), + any::().prop_map(|number| Value::Number(number.into())), + proptest::collection::vec(any::(), 0..8).prop_map(|items| { + Value::Array( + items + .into_iter() + .map(|number| Value::Number(number.into())) + .collect(), + ) + }), + Just(serde_json::json!({"nested": "credential"})), + ], + ) { + let key = if key_is_list { + CRED_ADMIN_TOKENS + } else { + CRED_ADMIN_TOKEN + }; + let error = bootstrap_from_json_value(key, value).unwrap_err(); + prop_assert!( + error.contains("must not be blank or null") + || error.contains("must be a non-empty JSON string"), + "unexpected credential error: {error}" + ); + } + + #[test] + fn header_authentication_preserves_rbac_write_semantics( + writer_token in "[A-Za-z0-9._~-]{1,24}", + actor in "[A-Za-z0-9._~-]{1,24}", + ) { + let reader_token = format!("{writer_token}-readonly"); + let wrong_token = format!("{writer_token}-wrong"); + let configured = format!( + "{writer_token}:{actor}:operator,{reader_token}:{actor}:readonly" + ); + let principals = parse_admin_tokens_strict(&configured) + .expect("generated admin-token configuration must be valid"); + prop_assert!(principals.get(&writer_token).is_some_and(|principal| principal.can_write)); + prop_assert!(principals.get(&reader_token).is_some_and(|principal| !principal.can_write)); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + runtime.block_on(async { + let app = build_app(AppState::seeded(None).with_admin_tokens(principals)); + + let unauthenticated = create_route_status(app.clone(), None).await; + let wrong = create_route_status(app.clone(), Some(&wrong_token)).await; + let readonly = create_route_status(app.clone(), Some(&reader_token)).await; + let writer = create_route_status(app, Some(&writer_token)).await; + + prop_assert_eq!(unauthenticated, StatusCode::UNAUTHORIZED); + prop_assert_eq!(wrong, StatusCode::UNAUTHORIZED); + prop_assert_eq!(readonly, StatusCode::FORBIDDEN); + prop_assert_eq!(writer, StatusCode::CREATED); + Ok(()) + })?; + } +} diff --git a/tests/binary.rs b/tests/binary.rs index ea49034f..5f80fbd2 100644 --- a/tests/binary.rs +++ b/tests/binary.rs @@ -40,9 +40,40 @@ fn binary_serves_until_force_stopped_on_windows() { ); } +#[test] +fn binary_fail_closes_non_loopback_listen_without_admin() { + let output = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "0.0.0.0:0") + .env_remove("ADMIN_TOKEN") + .env_remove("ADMIN_TOKENS") + .env_remove("WAF_IDS_CREDENTIALS_PATH") + .env_remove("WAF_IDS_STATE_PATH") + .output() + .expect("spawn gateway binary for fail-closed check"); + assert!( + !output.status.success(), + "public bind without credentials must exit non-zero: {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("refusing to bind 0.0.0.0:0"), + "operator error must name the refused address:\n{combined}" + ); + assert!( + !combined.contains("waf-ids-ai-soc listening on"), + "process must not print readiness after fail-closed startup:\n{combined}" + ); +} + fn spawn_ready_gateway() -> Child { let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) .env("BIND_ADDR", "127.0.0.1:0") + .env_remove("ADMIN_TOKEN") + .env_remove("ADMIN_TOKENS") + .env_remove("WAF_IDS_CREDENTIALS_PATH") .env_remove("WAF_IDS_STATE_PATH") .env_remove("EVENT_LIMIT") .env_remove("RATE_LIMIT") @@ -58,7 +89,7 @@ fn spawn_ready_gateway() -> Child { let mut line = String::new(); reader.read_line(&mut line).expect("read readiness line"); assert!( - line.contains("listening on"), + line.contains("waf-ids-ai-soc listening on"), "unexpected startup line: {line:?}" ); child diff --git a/tests/fuzz_invariants.rs b/tests/fuzz_invariants.rs index 746fc3cb..ab5adb8d 100644 --- a/tests/fuzz_invariants.rs +++ b/tests/fuzz_invariants.rs @@ -3,10 +3,12 @@ //! Mirrors the `fuzz_parse_admin_tokens` cargo-fuzz target (see `../fuzz`) but //! runs on stable in the normal `cargo test` suite. Parsing arbitrary operator //! config must never panic and must never emit an empty token key or empty -//! actor value. +//! actor value. The strict startup parser must also reject the ambiguous +//! separator, duplicate-secret, and role classes that public startup treats as +//! invalid configuration. use proptest::prelude::*; -use waf_ids_ai_soc::parse_admin_tokens; +use waf_ids_ai_soc::{parse_admin_tokens, parse_admin_tokens_strict}; proptest! { #[test] @@ -17,4 +19,68 @@ proptest! { prop_assert!(!principal.actor.is_empty(), "actor value must never be empty"); } } + + #[test] + fn parse_admin_tokens_strict_upholds_invariants_when_it_accepts(raw in ".*") { + if let Ok(tokens) = parse_admin_tokens_strict(&raw) { + for (token, principal) in &tokens { + prop_assert!(!token.is_empty(), "strict token key must never be empty"); + prop_assert!(!principal.actor.is_empty(), "strict actor value must never be empty"); + } + } + } + + #[test] + fn parse_admin_tokens_strict_rejects_duplicate_secrets( + token in "[A-Za-z0-9._~-]{1,32}", + first_actor in "[A-Za-z0-9._~-]{1,24}", + second_actor in "[A-Za-z0-9._~-]{1,24}", + ) { + let raw = format!("{token}:{first_actor},{token}:{second_actor}"); + let error = parse_admin_tokens_strict(&raw).unwrap_err(); + prop_assert!(error.contains("duplicate token"), "{error}"); + } + + #[test] + fn parse_admin_tokens_strict_rejects_blank_separator_entries( + token in "[A-Za-z0-9._~-]{1,32}", + actor in "[A-Za-z0-9._~-]{1,24}", + leading in any::(), + ) { + let valid = format!("{token}:{actor}"); + let raw = if leading { + format!(",{valid}") + } else { + format!("{valid},") + }; + let error = parse_admin_tokens_strict(&raw).unwrap_err(); + prop_assert!(error.contains("blank entry"), "{error}"); + } + + #[test] + fn parse_admin_tokens_strict_rejects_unknown_roles( + token in "[A-Za-z0-9._~-]{1,32}", + actor in "[A-Za-z0-9._~-]{1,24}", + role in "[A-Z]{5,16}", + ) { + prop_assume!(!matches!(role.to_ascii_lowercase().as_str(), + "admin" | "write" | "writer" | "operator" | "readonly" | "read" | "reader" | "ro")); + let raw = format!("{token}:{actor}:{role}"); + let error = parse_admin_tokens_strict(&raw).unwrap_err(); + prop_assert!(error.contains("not recognised"), "{error}"); + } + + #[test] + fn parse_admin_tokens_strict_preserves_write_role_semantics( + token in "[A-Za-z0-9._~-]{1,32}", + actor in "[A-Za-z0-9._~-]{1,24}", + writable in any::(), + ) { + let role = if writable { "operator" } else { "readonly" }; + let raw = format!("{token}:{actor}:{role}"); + let parsed = parse_admin_tokens_strict(&raw).unwrap(); + let principal = parsed.get(&token).expect("generated token must be present"); + prop_assert_eq!(principal.actor.as_str(), actor.as_str()); + prop_assert_eq!(principal.can_write, writable); + } } diff --git a/tests/smoke_script_security.rs b/tests/smoke_script_security.rs new file mode 100644 index 00000000..92e48f86 --- /dev/null +++ b/tests/smoke_script_security.rs @@ -0,0 +1,40 @@ +//! Security regression for the distributable smoke-test credential contract. +//! +//! The smoke harness is executed by operators and CI from a public repository. +//! It must mint an ephemeral per-process credential rather than carrying a +//! reusable administrative secret in source control. + +#[test] +fn smoke_script_mints_ephemeral_admin_credential() { + let script = include_str!("../scripts/smoke.sh"); + let token_generator = concat!( + "ADMIN_TOKEN_VALUE=\"$(python3 - <<'PY'\n", + "import secrets\n", + "print(secrets.token_hex(16))\n", + "PY\n", + ")\"" + ); + let token_forwarding = "ADMIN_TOKEN=\"$ADMIN_TOKEN_VALUE\" \\"; + + assert!( + !script.contains("dev-secret"), + "smoke.sh must not ship the historical reusable administrator credential" + ); + + let generator_position = script + .find(token_generator) + .expect("smoke.sh must assign ADMIN_TOKEN_VALUE from secrets.token_hex(16)"); + let forwarding_position = script + .find(token_forwarding) + .expect("smoke.sh must pass ADMIN_TOKEN_VALUE through as ADMIN_TOKEN"); + + assert!( + generator_position < forwarding_position, + "smoke.sh must mint the ephemeral administrator credential before forwarding it" + ); + assert_eq!( + script.matches("ADMIN_TOKEN=").count(), + 1, + "smoke.sh must expose exactly one ADMIN_TOKEN assignment so a fixed fallback cannot bypass the generated credential" + ); +}