diff --git a/README.md b/README.md index 0022cbc..8596a1d 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,17 @@ cargo run -- serve ## Self-hosting with Docker Published images are available from GitHub Container Registry after a release tag: -`ghcr.io/techgodhq/iris:` (or `:latest`). Iris currently has **no -HTTP authentication** (tracked in COD-429), so bind it to localhost or place it -only on a private network behind your own authenticated proxy. +`ghcr.io/techgodhq/iris:` (or `:latest`). Set `IRIS_API_TOKEN` to a +high-entropy secret in every internet-reachable deployment; every HTTP endpoint +except `GET /health` then requires `Authorization: Bearer `. Iris compares +tokens without early exit and does not encode assumptions about any particular +network product. Without a token, `iris serve` emits a conspicuous warning and +refuses public or wildcard bind addresses; it allows only numeric loopback, +private, carrier-grade-NAT, or IPv6 unique-local addresses. + +```bash +curl -H "Authorization: Bearer ${IRIS_API_TOKEN}" http://127.0.0.1:9876/providers +``` ```bash docker run --rm \ diff --git a/crates/iris-cli/src/commands.rs b/crates/iris-cli/src/commands.rs index 9ea4ed9..3c9c364 100644 --- a/crates/iris-cli/src/commands.rs +++ b/crates/iris-cli/src/commands.rs @@ -326,7 +326,43 @@ pub fn list_providers() -> anyhow::Result<()> { Ok(()) } +/// Reject public listening addresses when no HTTP bearer token is configured. +/// +/// This intentionally names no overlay network: loopback, RFC1918, carrier-grade +/// NAT, and IPv6 unique-local addresses are safe deployment classes regardless of +/// which network product provides them. +fn validate_unauthenticated_bind(addr: &str, has_api_token: bool) -> anyhow::Result<()> { + if has_api_token { + return Ok(()); + } + let address: std::net::SocketAddr = addr.parse().map_err(|_| { + anyhow::anyhow!("--addr must be a numeric socket address when IRIS_API_TOKEN is unset") + })?; + let private = match address.ip() { + std::net::IpAddr::V4(ip) => { + ip.is_loopback() + || ip.is_private() + || (ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1])) + } + std::net::IpAddr::V6(ip) => ip.is_loopback() || (ip.segments()[0] & 0xfe00) == 0xfc00, + }; + anyhow::ensure!( + private, + "IRIS_API_TOKEN is required when binding Iris to a public address" + ); + Ok(()) +} + pub async fn serve(args: ServeArgs) -> anyhow::Result<()> { + let api_token = std::env::var("IRIS_API_TOKEN") + .ok() + .filter(|token| !token.trim().is_empty()); + validate_unauthenticated_bind(&args.addr, api_token.is_some())?; + if api_token.is_none() { + tracing::warn!( + "IRIS_API_TOKEN is unset: HTTP API authentication is disabled; binding is restricted to a non-public address" + ); + } let store = attachment_store(); let audit = audit_log(); let providers = get_providers_from_path(args.config.as_deref(), &store, &audit)?; @@ -339,13 +375,15 @@ pub async fn serve(args: ServeArgs) -> anyhow::Result<()> { None => load_default_config()?, }; let (ingest, ingest_sources, ingest_secrets) = ingest_configuration(&config); - let app = iris_server::create_app_with_ingest( + let app = iris_server::create_app_with_ingest_sse_and_api_token( providers.clone(), store, audit, ingest, ingest_sources, ingest_secrets, + iris_server::sse::SseSettings::default(), + api_token, ); let listener = tokio::net::TcpListener::bind(&args.addr).await?; diff --git a/crates/iris-server/src/app.rs b/crates/iris-server/src/app.rs index 5359179..24cbe2a 100644 --- a/crates/iris-server/src/app.rs +++ b/crates/iris-server/src/app.rs @@ -29,6 +29,8 @@ pub struct AppState { pub ingest_secrets: BTreeMap>, /// SSE delivery settings (wire-idle heartbeat interval). pub sse: SseSettings, + /// Optional static bearer token for the general HTTP API. + pub api_token: Option>, } /// Creates the Axum application with all routes wired. @@ -93,6 +95,30 @@ pub fn create_app_with_ingest_and_sse( ingest_sources: impl IntoIterator, ingest_secrets: BTreeMap, sse: SseSettings, +) -> Router { + create_app_with_ingest_sse_and_api_token( + providers, + attachments, + audit, + ingest, + ingest_sources, + ingest_secrets, + sse, + None, + ) +} + +/// Creates the application with explicit ingestion, SSE, and HTTP bearer-token settings. +#[allow(clippy::too_many_arguments)] +pub fn create_app_with_ingest_sse_and_api_token( + providers: Vec>, + attachments: Arc, + audit: Arc, + ingest: Option>, + ingest_sources: impl IntoIterator, + ingest_secrets: BTreeMap, + sse: SseSettings, + api_token: Option, ) -> Router { let ingest_sources = ingest_sources.into_iter().collect(); // A blank configuration value must never turn into a valid empty bearer token. @@ -110,6 +136,9 @@ pub fn create_app_with_ingest_and_sse( ingest_sources, ingest_secrets, sse, + api_token: api_token + .filter(|token| !token.trim().is_empty()) + .map(Arc::from), }; routes::router(state) } diff --git a/crates/iris-server/src/lib.rs b/crates/iris-server/src/lib.rs index 0dfae79..173be09 100644 --- a/crates/iris-server/src/lib.rs +++ b/crates/iris-server/src/lib.rs @@ -8,5 +8,8 @@ pub mod app; pub mod routes; pub mod sse; -pub use app::{create_app, create_app_with_ingest, create_app_with_sse}; +pub use app::{ + create_app, create_app_with_ingest, create_app_with_ingest_sse_and_api_token, + create_app_with_sse, +}; pub use sse::SseSettings; diff --git a/crates/iris-server/src/routes.rs b/crates/iris-server/src/routes.rs index 339d9f2..5225d1c 100644 --- a/crates/iris-server/src/routes.rs +++ b/crates/iris-server/src/routes.rs @@ -88,7 +88,37 @@ pub fn router(state: AppState) -> Router { .any(|route| route.name == "subscribe_events" && route.path == "/v1/events"), "generated SSE metadata must cover the runtime-bound subscribe_events route" ); - router.with_state(state) + router + .layer(middleware::from_fn_with_state(state.clone(), api_auth)) + .with_state(state) +} + +/// Authenticate every HTTP endpoint except the liveness probe. +async fn api_auth( + State(state): State, + request: Request, + next: Next, +) -> Response { + if request.uri().path() == "/health" || state.api_token.is_none() { + return next.run(request).await; + } + let authorization = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + if authorization.is_none_or(|provided| { + !constant_time_secret_eq(state.api_token.as_deref().expect("checked above"), provided) + }) { + return ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "invalid or missing bearer authorization".to_string(), + }), + ) + .into_response(); + } + next.run(request).await } const MAX_INGEST_BODY_BYTES: usize = 1024 * 1024; @@ -952,6 +982,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, } } @@ -1180,6 +1211,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, }; let _router = super::router(app_state); } @@ -1278,6 +1310,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, }; let app = router(app_state); let providers = app @@ -1502,6 +1535,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, }; let mut input = input_with_thread(thread_id, &[]); input.body = serde_json::json!({ @@ -1630,6 +1664,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, }; // Retrieve via the HTTP handler. @@ -1666,6 +1701,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, }; let random_id = Uuid::new_v4(); @@ -1691,6 +1727,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, }; let response = get_attachment_content( @@ -1727,6 +1764,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, }; let response = get_attachment_content( @@ -1770,6 +1808,7 @@ mod tests { ingest_sources: std::collections::BTreeSet::new(), ingest_secrets: std::collections::BTreeMap::new(), sse: crate::sse::SseSettings::default(), + api_token: None, }; let response = get_attachment_content(