diff --git a/Cargo.lock b/Cargo.lock index eb413380..f0210f44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,12 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crypto-common" version = "0.1.7" @@ -243,6 +249,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "der" version = "0.7.10" @@ -299,6 +311,18 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "errno" version = "0.3.14" @@ -458,6 +482,37 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.5", + "ring", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + [[package]] name = "hmac" version = "0.13.0" @@ -846,6 +901,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "parking_lot" @@ -901,6 +960,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "postgres-protocol" version = "0.6.12" @@ -1810,6 +1875,9 @@ dependencies = [ "axum", "base64", "futures-util", + "hickory-proto", + "hyper", + "hyper-util", "libloading", "proptest", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index c7707c39..4915ed73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ axum = "0.8" base64 = "0.22" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json", "stream"] } futures-util = { version = "0.3", default-features = false, features = ["std"] } +hickory-proto = "0.25" +hyper = "1" +hyper-util = { version = "0.1", features = ["tokio"] } libloading = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 738fea36..ca478d7f 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,11 @@ 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` +- `EGRESS_PROXY_TOKEN`: dedicated browser-proxy password. Secret; prefer the + `egress_proxy_token` key in `WAF_IDS_CREDENTIALS_PATH`. +- `EGRESS_DNS_BIND_ADDR`: optional internal UDP+TCP DNS listener address, for + example `0.0.0.0:5353`. Only public A/AAAA answers are returned and cached + for 30 seconds. - `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. CIDR matches apply per resolved address and also authorize non-default ports. Loopback/private/metadata/site-local destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). After a host is allowed, outbound HTTP connects only to those evaluated addresses (original Host/SNI). `/healthz.destination_mode` reports `production` or `development`. - `WAF_IDS_STATE_PATH`: optional JSON state path for loopback/community. When omitted, the service runs with seeded in-memory state. Production (non-loopback) binds require `CONTROL_PLANE_DATABASE_URL` instead. - `CONTROL_PLANE_DATABASE_URL`: PostgreSQL URL for the production control plane (`postgres://…`). Secret; prefer `WAF_IDS_CREDENTIALS_PATH` key `control_plane_url`. `sslmode=require` / `verify-full` uses rustls with Mozilla roots (certificates always verified). `sslmode=disable` or omitted is plaintext. `allow`/`prefer` are rejected. After migrate, the session runs as `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS). `security_event` is HASH-partitioned by `tenant_id`. `/healthz.persistence` reports `postgres` when connected; `/healthz.event_partitions` reports the child count. @@ -123,6 +128,9 @@ and caps `max_bytes` at 8 MiB. A missing, malformed, or unsupported `code` and safe `error` fields. Security research grounding is recorded in [`docs/research/outbound-egress-security.md`](docs/research/outbound-egress-security.md). +Camoufox/Firefox network enforcement uses both the DNS listener and authenticated +HTTP CONNECT proxy; see [`docs/camoufox-egress.md`](docs/camoufox-egress.md). + Add a blocking route: ```bash diff --git a/deploy/kubernetes/waf-ids-ai-soc.yaml b/deploy/kubernetes/waf-ids-ai-soc.yaml index f811ecb4..28ce1e3e 100644 --- a/deploy/kubernetes/waf-ids-ai-soc.yaml +++ b/deploy/kubernetes/waf-ids-ai-soc.yaml @@ -11,6 +11,7 @@ metadata: type: Opaque stringData: ADMIN_TOKEN: replace-with-secret-manager-sync + EGRESS_PROXY_TOKEN: replace-with-separate-secret-manager-sync --- apiVersion: v1 kind: PersistentVolumeClaim @@ -53,11 +54,19 @@ spec: ports: - containerPort: 8080 name: http + - containerPort: 5353 + name: egress-dns-udp + protocol: UDP + - containerPort: 5353 + name: egress-dns-tcp + protocol: TCP env: - name: BIND_ADDR value: 0.0.0.0:8080 - name: DNSBL_ORIGIN value: dnsbl.example + - name: EGRESS_DNS_BIND_ADDR + value: 0.0.0.0:5353 - name: EVENT_LIMIT value: "1000" - name: WAF_IDS_STATE_PATH @@ -67,6 +76,11 @@ spec: secretKeyRef: name: waf-ids-ai-soc-admin key: ADMIN_TOKEN + - name: EGRESS_PROXY_TOKEN + valueFrom: + secretKeyRef: + name: waf-ids-ai-soc-admin + key: EGRESS_PROXY_TOKEN volumeMounts: - name: state mountPath: /var/lib/waf-ids-ai-soc @@ -112,3 +126,11 @@ spec: - name: http port: 80 targetPort: http + - name: egress-dns-udp + port: 53 + targetPort: egress-dns-udp + protocol: UDP + - name: egress-dns-tcp + port: 53 + targetPort: egress-dns-tcp + protocol: TCP diff --git a/docs/camoufox-egress.md b/docs/camoufox-egress.md new file mode 100644 index 00000000..b9543d30 --- /dev/null +++ b/docs/camoufox-egress.md @@ -0,0 +1,49 @@ +# Camoufox egress contract + +Wardnet is both the container DNS resolver and the only HTTPS egress path. A +preflight URL approval is not a security boundary: the browser navigation must +use the CONNECT proxy and the workload network must deny direct Internet egress. + +## Wardnet + +Seed the credential registry with a dedicated `egress_proxy_token` (the +`EGRESS_PROXY_TOKEN` environment variable is bootstrap transport only), set +`BIND_ADDR=0.0.0.0:8080`, and set `EGRESS_DNS_BIND_ADDR=0.0.0.0:5353` on the +internal workload network. Do not expose port 5353 publicly. The Kubernetes +Service maps its internal port 53 to this unprivileged container port. + +The DNS listener supports bounded UDP and TCP A/AAAA queries. It runs every new +name through `DestinationPolicy`, returns no private, loopback, link-local, +metadata, or otherwise denied address, caches the approved address set for 30 +seconds, caps the cache at 1024 names, and refuses other record types. TCP DNS +messages are capped at 4096 bytes and concurrent TCP clients at 64. + +The HTTP endpoint accepts authenticated `CONNECT host:443` only. Configure +Basic proxy credentials as username `wardnet` and password equal to the +dedicated proxy token. Wardnet resolves through the same policy/cache and opens +the upstream socket directly to an approved IP; it never performs a second +connect-time DNS lookup. A redirect to another origin therefore requires a new +policy-checked CONNECT tunnel. + +## Camoufox / contextual-orchestrator + +Provide these values from the deployment layer: + +```text +DNS nameserver: (UDP and TCP port 53) +HTTP/HTTPS proxy: http://:8080 +Proxy username: wardnet +Proxy password: +Firefox DoH/TRR: disabled (network.trr.mode=5) +``` + +Configure the container runtime DNS address and the Camoufox proxy launch +option; setting only one is incomplete. Do not pass the Wardnet admin token to +the browser container. + +Enforce a default-deny egress policy on the Camoufox workload. Its only allowed +egress is UDP/TCP DNS to the Wardnet Service port 53 (target port 5353) and TCP +to Wardnet port 8080. In +particular, deny direct TCP 80/443 and all other DNS servers. Wardnet separately +needs upstream DNS and TCP 443. This network policy is what prevents a browser, +extension, subprocess, or IP-literal URL from bypassing the proxy contract. diff --git a/src/credentials.rs b/src/credentials.rs index 66223ab5..20f3e057 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -12,6 +12,7 @@ use std::{collections::HashMap, io::ErrorKind, path::Path}; pub const CRED_ADMIN_TOKEN: &str = "admin_token"; pub const CRED_ADMIN_TOKENS: &str = "admin_tokens"; pub const CRED_CONTROL_PLANE_URL: &str = "control_plane_url"; +pub const CRED_EGRESS_PROXY_TOKEN: &str = "egress_proxy_token"; /// Where secret-bearing credentials were loaded from (never includes values). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -75,6 +76,7 @@ impl CredentialRegistry { env_admin_token: Option, env_admin_tokens: Option, env_control_plane_url: Option, + env_egress_proxy_token: Option, ) -> Result { let mut values = HashMap::new(); let mut from_file = false; @@ -90,7 +92,12 @@ impl CredentialRegistry { path.display() ) })?; - for key in [CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_CONTROL_PLANE_URL] { + for key in [ + CRED_ADMIN_TOKEN, + CRED_ADMIN_TOKENS, + CRED_CONTROL_PLANE_URL, + CRED_EGRESS_PROXY_TOKEN, + ] { if let Some(raw) = file_map.get(key) { let text = json_value_as_nonempty_string(raw); if let Some(text) = text { @@ -128,6 +135,12 @@ impl CredentialRegistry { values.insert(CRED_CONTROL_PLANE_URL.to_string(), url); from_env = true; } + if !values.contains_key(CRED_EGRESS_PROXY_TOKEN) + && let Some(token) = env_egress_proxy_token.filter(|value| !value.is_empty()) + { + values.insert(CRED_EGRESS_PROXY_TOKEN.to_string(), token); + from_env = true; + } let source = if from_file { CredentialSource::File @@ -168,6 +181,7 @@ mod tests { Some("secret".to_string()), Some("tok:alice".to_string()), None, + Some("proxy-secret".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -176,13 +190,18 @@ mod tests { registry.get_credential(CRED_ADMIN_TOKENS), Some("tok:alice") ); + assert_eq!( + registry.get_credential(CRED_EGRESS_PROXY_TOKEN), + Some("proxy-secret") + ); assert!(registry.has_admin_auth()); } #[test] fn bootstrap_empty_when_no_secrets() { let registry = - CredentialRegistry::bootstrap_secrets(None, None, Some(String::new()), None).unwrap(); + CredentialRegistry::bootstrap_secrets(None, None, Some(String::new()), None, None) + .unwrap(); assert_eq!(registry.source(), CredentialSource::None); assert!(!registry.has_admin_auth()); } @@ -212,6 +231,7 @@ mod tests { Some("from-env".to_string()), Some("envtok:env".to_string()), None, + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -243,6 +263,7 @@ mod tests { Some("ignored".to_string()), Some("envtok:bob".to_string()), None, + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -270,6 +291,7 @@ mod tests { Some("env-secret".to_string()), None, None, + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -292,7 +314,8 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("credentials.json"); std::fs::write(&path, "not-json").unwrap(); - let err = CredentialRegistry::bootstrap_secrets(Some(&path), None, None, None).unwrap_err(); + let err = + CredentialRegistry::bootstrap_secrets(Some(&path), None, None, None, None).unwrap_err(); assert!(err.contains("not valid JSON")); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/egress_dns.rs b/src/egress_dns.rs new file mode 100644 index 00000000..530dc103 --- /dev/null +++ b/src/egress_dns.rs @@ -0,0 +1,249 @@ +use crate::AppState; +use hickory_proto::{ + op::{Message, MessageType, OpCode, ResponseCode}, + rr::{ + RData, Record, RecordType, + rdata::{A, AAAA}, + }, + serialize::binary::{BinDecodable, BinEncodable, BinEncoder}, +}; +use std::{sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream, UdpSocket}, + sync::{Notify, Semaphore}, +}; + +const DNS_PACKET_MAX_BYTES: usize = 4096; +const DNS_TTL_SECONDS: u32 = 30; +const DNS_MAX_IN_FLIGHT: usize = 64; +const DNS_MAX_ANSWERS: usize = 16; + +async fn answer(state: &AppState, packet: &[u8]) -> Option> { + let request = Message::from_bytes(packet).ok()?; + let mut response = Message::new(); + response + .set_id(request.id()) + .set_message_type(MessageType::Response) + .set_op_code(OpCode::Query) + .set_recursion_desired(request.recursion_desired()) + .set_recursion_available(true); + for query in request.queries() { + response.add_query(query.clone()); + } + if request.queries().len() != 1 { + response.set_response_code(ResponseCode::FormErr); + return encode(response); + } + let query = &request.queries()[0]; + if !matches!(query.query_type(), RecordType::A | RecordType::AAAA) { + response.set_response_code(ResponseCode::NotImp); + return encode(response); + } + let host = query.name().to_utf8().trim_end_matches('.').to_string(); + let addresses = match state.egress_dns.lookup(&host).await { + Some(addresses) => addresses, + None => { + let decision = state + .resolve_outbound(&format!("https://{host}/")) + .await + .ok(); + let Some(decision) = decision else { + response.set_response_code(ResponseCode::Refused); + return encode(response); + }; + state.egress_dns.record(&host, &decision.ips).await; + decision.ips + } + }; + for address in addresses.into_iter().take(DNS_MAX_ANSWERS) { + let data = match (query.query_type(), address) { + (RecordType::A, std::net::IpAddr::V4(address)) => RData::A(A(address)), + (RecordType::AAAA, std::net::IpAddr::V6(address)) => RData::AAAA(AAAA(address)), + _ => continue, + }; + response.add_answer(Record::from_rdata( + query.name().clone(), + DNS_TTL_SECONDS, + data, + )); + } + encode(response) +} + +fn encode(message: Message) -> Option> { + let mut bytes = Vec::with_capacity(512); + let mut encoder = BinEncoder::new(&mut bytes); + message.emit(&mut encoder).ok()?; + Some(bytes) +} + +pub async fn serve(state: AppState, udp: UdpSocket, tcp: TcpListener, stop: Arc) { + let state_udp = state.clone(); + let stop_udp = Arc::clone(&stop); + let udp_task = tokio::spawn(async move { + let udp = Arc::new(udp); + let permits = Arc::new(Semaphore::new(DNS_MAX_IN_FLIGHT)); + let mut packet = [0_u8; DNS_PACKET_MAX_BYTES]; + loop { + tokio::select! { + _ = stop_udp.notified() => break, + received = udp.recv_from(&mut packet) => { + let Ok((length, peer)) = received else { continue }; + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { continue }; + let packet = packet[..length].to_vec(); + let state = state_udp.clone(); + let udp = Arc::clone(&udp); + tokio::spawn(async move { + let _permit = permit; + if let Some(response) = answer(&state, &packet).await { + let _ = udp.send_to(&response, peer).await; + } + }); + } + } + } + }); + + let permits = Arc::new(Semaphore::new(DNS_MAX_IN_FLIGHT)); + loop { + tokio::select! { + _ = stop.notified() => break, + accepted = tcp.accept() => { + let Ok((stream, _)) = accepted else { continue }; + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { continue }; + let state = state.clone(); + tokio::spawn(async move { + let _permit = permit; + let _ = serve_tcp(state, stream).await; + }); + } + } + } + udp_task.abort(); +} + +async fn serve_tcp(state: AppState, mut stream: TcpStream) -> std::io::Result<()> { + let length = tokio::time::timeout(Duration::from_secs(5), stream.read_u16()).await?? as usize; + if length == 0 || length > DNS_PACKET_MAX_BYTES { + return Ok(()); + } + let mut packet = vec![0; length]; + tokio::time::timeout(Duration::from_secs(5), stream.read_exact(&mut packet)).await??; + if let Some(response) = answer(&state, &packet).await { + stream.write_u16(response.len() as u16).await?; + stream.write_all(&response).await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use hickory_proto::{op::Query, rr::Name}; + use std::net::IpAddr; + + struct StaticResolver(Vec); + + impl crate::HostResolver for StaticResolver { + fn resolve(&self, _host: &str) -> Result, String> { + Ok(self.0.clone()) + } + } + + struct SlowResolver; + + impl crate::HostResolver for SlowResolver { + fn resolve(&self, host: &str) -> Result, String> { + if host == "slow.example" { + std::thread::sleep(Duration::from_millis(300)); + } + Ok(vec!["8.8.8.8".parse().unwrap()]) + } + } + + fn query_packet(id: u16, host: &str) -> Vec { + let mut request = Message::new(); + request.set_id(id).add_query(Query::query( + Name::from_ascii(format!("{host}.")).unwrap(), + RecordType::A, + )); + encode(request).unwrap() + } + + #[tokio::test] + async fn refuses_private_answers_and_unsupported_types() { + let state = AppState::seeded(None) + .with_destination_policy(crate::DestinationPolicy::production()) + .with_resolver(Arc::new(StaticResolver(vec!["127.0.0.1".parse().unwrap()]))); + let mut request = Message::new(); + request.set_id(7).add_query(Query::query( + Name::from_ascii("localhost.").unwrap(), + RecordType::A, + )); + let response = + Message::from_bytes(&answer(&state, &encode(request).unwrap()).await.unwrap()).unwrap(); + assert_eq!(response.response_code(), ResponseCode::Refused); + + let mut request = Message::new(); + request.add_query(Query::query( + Name::from_ascii("example.com.").unwrap(), + RecordType::MX, + )); + let response = + Message::from_bytes(&answer(&state, &encode(request).unwrap()).await.unwrap()).unwrap(); + assert_eq!(response.response_code(), ResponseCode::NotImp); + } + + #[tokio::test] + async fn returns_and_caches_only_policy_approved_address_family() { + let state = AppState::seeded(None) + .with_destination_policy(crate::DestinationPolicy::production()) + .with_resolver(Arc::new(StaticResolver(vec![ + "8.8.8.8".parse().unwrap(), + "2001:4860:4860::8888".parse().unwrap(), + ]))); + let mut request = Message::new(); + request.add_query(Query::query( + Name::from_ascii("public.example.").unwrap(), + RecordType::A, + )); + let response = + Message::from_bytes(&answer(&state, &encode(request).unwrap()).await.unwrap()).unwrap(); + assert_eq!(response.response_code(), ResponseCode::NoError); + assert_eq!(response.answers().len(), 1); + assert_eq!(response.answers()[0].ttl(), DNS_TTL_SECONDS); + assert_eq!( + state.egress_dns.lookup("PUBLIC.EXAMPLE.").await, + Some(vec![ + "8.8.8.8".parse().unwrap(), + "2001:4860:4860::8888".parse().unwrap() + ]) + ); + } + + #[tokio::test] + async fn udp_fast_query_is_not_blocked_by_slow_resolution() { + let state = AppState::seeded(None) + .with_destination_policy(crate::DestinationPolicy::production()) + .with_resolver(Arc::new(SlowResolver)); + let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = udp.local_addr().unwrap(); + let tcp = TcpListener::bind(address).await.unwrap(); + let stop = Arc::new(Notify::new()); + let server = tokio::spawn(serve(state, udp, tcp, Arc::clone(&stop))); + let client = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + client.connect(address).await.unwrap(); + client.send(&query_packet(1, "slow.example")).await.unwrap(); + client.send(&query_packet(2, "fast.example")).await.unwrap(); + + let mut response = [0_u8; DNS_PACKET_MAX_BYTES]; + let length = tokio::time::timeout(Duration::from_millis(200), client.recv(&mut response)) + .await + .expect("fast query must not wait for slow DNS") + .unwrap(); + assert_eq!(Message::from_bytes(&response[..length]).unwrap().id(), 2); + stop.notify_waiters(); + server.await.unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1b0ca33f..ea91a5ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,8 @@ use axum::{ Json, Router, body::Bytes, - extract::{DefaultBodyLimit, Path as PathParam, Query, State}, - http::{HeaderMap, Method, StatusCode, Uri}, + extract::{DefaultBodyLimit, Path as PathParam, Query, Request, State}, + http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}, response::{Html, IntoResponse, Response}, routing::{any, get, post}, }; @@ -11,13 +11,15 @@ use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, io::ErrorKind, - net::{IpAddr, Ipv4Addr}, + net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, sync::Arc, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tokio::{ fs, + io::copy_bidirectional, + net::TcpStream, sync::{Mutex, RwLock}, }; use waf_ids_core::{ @@ -36,11 +38,44 @@ pub use waf_ids_core::{ ThreatIndicator, export_dnsbl_zone, ip_in_network, reverse_ipv4_for_dnsbl, score_request, }; +const EGRESS_DNS_TTL: Duration = Duration::from_secs(30); +const EGRESS_DNS_MAX_ENTRIES: usize = 1024; + +#[derive(Default)] +struct EgressDnsCache { + inner: Mutex)>>, +} + +impl EgressDnsCache { + async fn lookup(&self, host: &str) -> Option> { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + let mut cache = self.inner.lock().await; + let (expires, addresses) = cache.get(&host)?; + if *expires <= Instant::now() { + cache.remove(&host); + return None; + } + Some(addresses.clone()) + } + + async fn record(&self, host: &str, addresses: &[IpAddr]) { + let mut cache = self.inner.lock().await; + if cache.len() >= EGRESS_DNS_MAX_ENTRIES { + cache.clear(); + } + cache.insert( + host.trim_end_matches('.').to_ascii_lowercase(), + (Instant::now() + EGRESS_DNS_TTL, addresses.to_vec()), + ); + } +} + mod control_plane; mod coraza_audit; mod coraza_inprocess; mod credentials; mod destination; +mod egress_dns; mod misp_import; mod opencti_import; mod outbox; @@ -49,8 +84,8 @@ mod stix_import; mod suricata_eve; mod taxii; pub use credentials::{ - CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_CONTROL_PLANE_URL, CredentialRegistry, - CredentialSource, + CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_CONTROL_PLANE_URL, CRED_EGRESS_PROXY_TOKEN, + CredentialRegistry, CredentialSource, }; pub use destination::{DestinationPolicy, HostResolver, SystemHostResolver}; pub use proven_engine::{ProvenEngineConfig, ProvenEngineOutcome}; @@ -65,6 +100,8 @@ pub struct AppState { // RBAC: multiple admin tokens each mapped to an actor + write capability. // Empty falls back to the single `admin_token`. Token values are never logged. admin_tokens: HashMap, + /// Dedicated browser-proxy password loaded from the credential registry. + egress_proxy_token: Option, /// Where admin secrets were bootstrapped from (file/env/none). Never holds values. credentials_source: CredentialSource, state_path: Option, @@ -90,6 +127,7 @@ pub struct AppState { /// Addresses that already passed policy; the HTTP clients resolve through /// this pin board instead of a second OS DNS lookup. pins: Arc, + egress_dns: Arc, /// PostgreSQL snapshot store. `None` keeps the JSON-file / memory adapter. control_plane: Option>, } @@ -165,6 +203,7 @@ impl AppState { feed_http: outbound_http_client(Arc::clone(&pins)), admin_token: config.admin_token, admin_tokens: HashMap::new(), + egress_proxy_token: None, credentials_source: CredentialSource::None, state_path: config.state_path, dnsbl_origin: normalized_origin(&config.dnsbl_origin), @@ -179,6 +218,7 @@ impl AppState { destination: DestinationPolicy::production(), resolver: Arc::new(SystemHostResolver), pins, + egress_dns: Arc::new(EgressDnsCache::default()), control_plane: None, } } @@ -268,6 +308,11 @@ impl AppState { self } + pub fn with_egress_proxy_token(mut self, token: Option) -> Self { + self.egress_proxy_token = token.filter(|value| !value.is_empty()); + self + } + /// Record how admin secrets were bootstrapped into the process (never values). pub fn with_credentials_source(mut self, source: CredentialSource) -> Self { self.credentials_source = source; @@ -672,10 +717,115 @@ pub fn build_app(state: AppState) -> Router { .route("/api/support-bundle", get(support_bundle)) .route("/dnsbl/zone", get(dnsbl_zone)) .route("/gateway/{*path}", any(gateway)) + .fallback(connect_proxy) .layer(DefaultBodyLimit::max(max_body_bytes)) .with_state(state) } +fn proxy_authenticate() -> Response { + let mut response = ( + StatusCode::PROXY_AUTHENTICATION_REQUIRED, + "proxy authentication required", + ) + .into_response(); + response.headers_mut().insert( + header::PROXY_AUTHENTICATE, + HeaderValue::from_static("Basic realm=\"wardnet\""), + ); + response +} + +fn proxy_authorized(state: &AppState, headers: &HeaderMap) -> bool { + let Some(expected) = state.egress_proxy_token.as_deref() else { + return false; + }; + let Some(encoded) = headers + .get(header::PROXY_AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Basic ")) + else { + return false; + }; + let Ok(decoded) = BASE64.decode(encoded) else { + return false; + }; + let Ok(credentials) = std::str::from_utf8(&decoded) else { + return false; + }; + let Some((username, token)) = credentials.split_once(':') else { + return false; + }; + if username != "wardnet" || token.is_empty() { + return false; + } + token == expected +} + +async fn connect_proxy(State(state): State, mut request: Request) -> Response { + if request.method() != Method::CONNECT { + return StatusCode::NOT_FOUND.into_response(); + } + if !proxy_authorized(&state, request.headers()) { + return proxy_authenticate(); + } + let Some(authority) = request.uri().authority() else { + return (StatusCode::BAD_REQUEST, "CONNECT authority is required").into_response(); + }; + if authority.port_u16() != Some(443) { + return (StatusCode::FORBIDDEN, "CONNECT permits port 443 only").into_response(); + } + let host = authority.host().trim_end_matches('.'); + if host.is_empty() { + return (StatusCode::BAD_REQUEST, "CONNECT host is invalid").into_response(); + } + + let addresses = match state.egress_dns.lookup(host).await { + Some(addresses) => addresses, + None => { + let policy_url = if host.parse::().is_ok() { + format!("https://[{host}]/") + } else { + format!("https://{host}/") + }; + match state.resolve_outbound(&policy_url).await { + Ok(decision) => { + state.egress_dns.record(host, &decision.ips).await; + decision.ips + } + Err(_) => { + return (StatusCode::FORBIDDEN, "destination policy denied CONNECT") + .into_response(); + } + } + } + }; + + let mut upstream = None; + for address in addresses.into_iter().take(16) { + if let Ok(Ok(stream)) = tokio::time::timeout( + Duration::from_secs(5), + TcpStream::connect(SocketAddr::new(address, 443)), + ) + .await + { + upstream = Some(stream); + break; + } + } + let Some(mut upstream) = upstream else { + return (StatusCode::BAD_GATEWAY, "upstream connection failed").into_response(); + }; + + let upgrade = hyper::upgrade::on(&mut request); + tokio::spawn(async move { + if let Ok(upgraded) = upgrade.await { + let mut client = hyper_util::rt::TokioIo::new(upgraded); + let _ = copy_bidirectional(&mut client, &mut upstream).await; + } + }); + StatusCode::OK.into_response() +} + fn outbound_fetch_error(status: StatusCode, code: &'static str, message: &'static str) -> Response { ( status, @@ -3887,6 +4037,7 @@ pub async fn run_from_env( std::env::var("ADMIN_TOKEN").ok(), std::env::var("ADMIN_TOKENS").ok(), std::env::var("CONTROL_PLANE_DATABASE_URL").ok(), + std::env::var("EGRESS_PROXY_TOKEN").ok(), )?; let config = AppConfig { admin_token: credentials @@ -3964,17 +4115,45 @@ pub async fn run_from_env( let state = state .with_rate_limit(rate_limit, rate_limit_window) .with_admin_tokens(admin_tokens) + .with_egress_proxy_token( + credentials + .get_credential(CRED_EGRESS_PROXY_TOKEN) + .map(str::to_owned), + ) .with_credentials_source(credentials.source()) .with_proven_engine(proven_engine) .with_destination_policy(destination_policy) .with_max_body_size(max_body_bytes); let listener = tokio::net::TcpListener::bind(&bind_addr).await?; let local_addr = listener.local_addr()?; + let egress_dns = match std::env::var("EGRESS_DNS_BIND_ADDR") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + Some(bind) => { + let udp = tokio::net::UdpSocket::bind(&bind).await?; + let dns_addr = udp.local_addr()?; + let tcp = tokio::net::TcpListener::bind(dns_addr).await?; + Some((udp, tcp, dns_addr)) + } + None => None, + }; println!("waf-ids-ai-soc listening on http://{local_addr}"); + if let Some((_, _, dns_addr)) = &egress_dns { + println!("wardnet egress DNS listening on udp+tcp://{dns_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 stop_workers = Arc::new(tokio::sync::Notify::new()); + if let Some((udp, tcp, _)) = egress_dns { + let dns_state = state.clone(); + let stop = Arc::clone(&stop_workers); + tokio::spawn(async move { + egress_dns::serve(dns_state, udp, tcp, stop).await; + }); + } if state.control_plane.is_some() { let worker_state = state.clone(); let stop = Arc::clone(&stop_workers); @@ -4397,6 +4576,40 @@ mod tests { assert_eq!(insecure_body["code"], "invalid_url"); } + #[tokio::test] + async fn connect_proxy_requires_basic_auth_and_denies_private_destination() { + let state = AppState::seeded(None) + .with_egress_proxy_token(Some("secret".to_string())) + .with_destination_policy(DestinationPolicy::production()); + let app = build_app(state); + let request = Request::builder() + .method(Method::CONNECT) + .uri("localhost:443") + .body(Body::empty()) + .unwrap(); + let unauthorized = app_request(&app, request).await; + assert_eq!( + unauthorized.status(), + StatusCode::PROXY_AUTHENTICATION_REQUIRED + ); + assert_eq!( + unauthorized.headers()[header::PROXY_AUTHENTICATE], + "Basic realm=\"wardnet\"" + ); + + let request = Request::builder() + .method(Method::CONNECT) + .uri("localhost:443") + .header( + header::PROXY_AUTHORIZATION, + format!("Basic {}", BASE64.encode("wardnet:secret")), + ) + .body(Body::empty()) + .unwrap(); + let denied = app_request(&app, request).await; + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + } + #[tokio::test] async fn gateway_rate_limits_per_client_ip() { let app = build_app(AppState::seeded(None).with_rate_limit(2, 60));