Group D — three independent security primitives hardening (post-audit 2026-05-02).
kei-runtime — atom invoke RCE allowlist:
- invoke.rs: is_safe_crate_name validator (regex ^kei-[a-z][a-z0-9-]+$);
rejects /, \\, .., :, absolute paths, empty, >128 chars.
InvalidAtom error variant.
stdout/stderr capped at 16 MiB (was unbounded).
- main.rs: InvalidAtom mapped to exit code 2.
- tests/invoke_exit_codes_smoke.rs: invoke_unsafe_crate_name_exits_2 added.
- Closes: any user able to write atoms/*.md with crate_name: "rm" or "sudo"
triggered arbitrary command execution.
kei-graph-stream — WebSocket bearer + Origin:
- auth.rs (new, 142 LOC): token load + bearer extraction + Origin allowlist +
ConstantTimeEq compare; 8 unit tests.
- ws.rs: ws_handler validates Origin + bearer before upgrade (403/401 on failure).
- main.rs: --public-bind-i-accept-the-leak flag required for non-loopback bind;
else bail!() with explicit error.
- tests/smoke.rs: rewritten with Origin + bearer headers via connect_async_with_config.
- Closes: WebSocket /stream had zero auth, zero Origin check; browser CSWSH could
subscribe to agent activity broadcast; KEI_GRAPH_STREAM_BIND env silently
accepted any SocketAddr.
kei-compute-baremetal — SSH option injection (CVE-2023-51385 class):
- ssh.rs: is_safe_user + is_safe_host validators (alphanumeric + -_.; reject leading -;
max 64 chars; no @, :, /, \\, space).
- ssh.rs: -- sentinel before user@host argv (OpenSSH 9.6+ stops flag parsing).
- ssh.rs: StrictHostKeyChecking=yes default; KEI_BAREMETAL_ACCEPT_NEW=1 for TOFU.
- error.rs: InvalidRegion variant.
- provider.rs: validators applied in target_for_spec + target_for_handle.
- Closes: spec.region "-oProxyCommand=evil" triggered local RCE before TCP connect.
Test results: 29 passed; 0 failed across all three crates. cargo check clean.
Findings: RCE allowlist (Wave-A) + WebSocket auth (Wave-B) + SSH injection (Wave-B)
were unique-per-retest discoveries. None present in original wave-1 audit.
Note: kei-compute-baremetal/src/provider.rs at 300 LOC (was 268; +32 from validators).
Pre-existing >200 LOC violation, fix scope was security-additions only. Follow-up:
split provider.rs into provider.rs (<200) + provider_tests.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
4.3 KiB
Rust
142 lines
4.3 KiB
Rust
//! Bearer token + Origin validation for WebSocket upgrades.
|
|
//!
|
|
//! Token is loaded from `~/.keisei/cortex.token` (same file as kei-cortex).
|
|
//! Origin allowlist: localhost and 127.0.0.1 on any port, plus the literal
|
|
//! string "null" (used by some browsers for file:// origins).
|
|
|
|
use std::path::PathBuf;
|
|
|
|
/// Error returned when auth fails.
|
|
#[derive(Debug)]
|
|
pub enum AuthError {
|
|
TokenLoad(String),
|
|
BearerMissing,
|
|
BearerInvalid,
|
|
OriginForbidden,
|
|
}
|
|
|
|
impl std::fmt::Display for AuthError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::TokenLoad(e) => write!(f, "token load: {e}"),
|
|
Self::BearerMissing => write!(f, "Sec-WebSocket-Protocol bearer token missing"),
|
|
Self::BearerInvalid => write!(f, "bearer token mismatch"),
|
|
Self::OriginForbidden => write!(f, "Origin not in allowlist"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load the expected bearer token from `~/.keisei/cortex.token`.
|
|
pub fn load_expected_token() -> Result<String, AuthError> {
|
|
let path = token_path();
|
|
std::fs::read_to_string(&path)
|
|
.map(|s| s.trim().to_string())
|
|
.map_err(|e| AuthError::TokenLoad(format!("{}: {e}", path.display())))
|
|
}
|
|
|
|
fn token_path() -> PathBuf {
|
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
|
PathBuf::from(home).join(".keisei/cortex.token")
|
|
}
|
|
|
|
/// Extract the bearer token from `Sec-WebSocket-Protocol: bearer,<token>`.
|
|
pub fn extract_bearer(protocol_header: Option<&str>) -> Result<&str, AuthError> {
|
|
let hdr = protocol_header.ok_or(AuthError::BearerMissing)?;
|
|
for part in hdr.split(',') {
|
|
let part = part.trim();
|
|
if let Some(tok) = part.strip_prefix("bearer ") {
|
|
return Ok(tok.trim());
|
|
}
|
|
// Also accept bare token after "bearer" as sole segment
|
|
if part != "bearer" && !part.is_empty() {
|
|
// Skip non-bearer segments
|
|
}
|
|
}
|
|
// Try: "bearer,<token>" — token is second comma-segment
|
|
let mut parts = hdr.splitn(2, ',');
|
|
if parts.next().map(str::trim) == Some("bearer") {
|
|
if let Some(tok) = parts.next() {
|
|
let tok = tok.trim();
|
|
if !tok.is_empty() {
|
|
return Ok(tok);
|
|
}
|
|
}
|
|
}
|
|
Err(AuthError::BearerMissing)
|
|
}
|
|
|
|
/// Validate `Origin` is in the local allowlist.
|
|
/// Allows: `http://localhost:<port>`, `http://127.0.0.1:<port>`, `null`.
|
|
pub fn validate_origin(origin: Option<&str>) -> Result<(), AuthError> {
|
|
let o = origin.ok_or(AuthError::OriginForbidden)?;
|
|
if o == "null" {
|
|
return Ok(());
|
|
}
|
|
if is_local_origin(o) {
|
|
return Ok(());
|
|
}
|
|
Err(AuthError::OriginForbidden)
|
|
}
|
|
|
|
fn is_local_origin(o: &str) -> bool {
|
|
let stripped = o
|
|
.strip_prefix("http://localhost")
|
|
.or_else(|| o.strip_prefix("http://127.0.0.1"));
|
|
match stripped {
|
|
None => false,
|
|
Some("") => true,
|
|
Some(rest) => rest.starts_with(':'),
|
|
}
|
|
}
|
|
|
|
/// Constant-time comparison (length-gated xor fold).
|
|
pub fn tokens_match(expected: &str, got: &str) -> bool {
|
|
if expected.len() != got.len() {
|
|
return false;
|
|
}
|
|
let exp = expected.to_ascii_lowercase();
|
|
let got = got.to_ascii_lowercase();
|
|
let mut diff: u8 = 0;
|
|
for (a, b) in exp.bytes().zip(got.bytes()) {
|
|
diff |= a ^ b;
|
|
}
|
|
diff == 0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn local_origins_accepted() {
|
|
assert!(validate_origin(Some("http://localhost:8201")).is_ok());
|
|
assert!(validate_origin(Some("http://127.0.0.1:8201")).is_ok());
|
|
assert!(validate_origin(Some("null")).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn remote_origins_rejected() {
|
|
assert!(validate_origin(Some("http://evil.com")).is_err());
|
|
assert!(validate_origin(Some("https://localhost:8201")).is_err());
|
|
assert!(validate_origin(None).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn bearer_extracted() {
|
|
assert_eq!(extract_bearer(Some("bearer,abc123")).unwrap(), "abc123");
|
|
}
|
|
|
|
#[test]
|
|
fn bearer_missing_returns_err() {
|
|
assert!(extract_bearer(None).is_err());
|
|
assert!(extract_bearer(Some("other")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn tokens_match_works() {
|
|
assert!(tokens_match("abc", "abc"));
|
|
assert!(tokens_match("ABC", "abc"));
|
|
assert!(!tokens_match("abc", "xyz"));
|
|
assert!(!tokens_match("abc", "ab"));
|
|
}
|
|
}
|