From 550cd2c692172389c72856169924ab4287fe0053 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 21:08:06 +0900 Subject: [PATCH 1/4] feat(security): add bounded outbound fetch API --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 14 +++ src/lib.rs | 266 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 831e74ff..eb413380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1808,6 +1808,7 @@ name = "waf-ids-ai-soc" version = "0.1.0" dependencies = [ "axum", + "base64", "futures-util", "libloading", "proptest", diff --git a/Cargo.toml b/Cargo.toml index 7b9bc242..c7707c39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ resolver = "3" [dependencies] 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"] } libloading = "0.8" diff --git a/README.md b/README.md index 35bd2d23..e78ea0bb 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,20 @@ curl http://127.0.0.1:8080/dnsbl/zone curl http://127.0.0.1:8080/gateway/demo?q=union%20select ``` +Fetch a public HTTPS document through Wardnet's destination policy and pinned DNS: + +```bash +curl -X POST http://127.0.0.1:8080/api/outbound/fetch \ + -H 'content-type: application/json' \ + -H 'x-admin-token: dev-secret' \ + -d '{"url":"https://example.com/privacy","max_bytes":524288}' +``` + +The JSON response contains `status`, `content_type`, `final_url`, `body_base64`, +and `redirects`. Wardnet follows at most three HTTPS redirects, revalidates and +pins DNS at every hop, disables ambient proxies, accepts document content types, +and caps `max_bytes` at 8 MiB. Errors return stable `code` and safe `error` fields. + Add a blocking route: ```bash diff --git a/src/lib.rs b/src/lib.rs index 6fd6c271..47da8342 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ use axum::{ response::{Html, IntoResponse, Response}, routing::{any, get, post}, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, @@ -538,6 +539,9 @@ const PHISHING_DATABASE_FETCH_TIMEOUT_SECS: u64 = 15; /// Bounded wait for blocking OS DNS inside destination-policy evaluation. const DESTINATION_RESOLVE_TIMEOUT: Duration = Duration::from_secs(2); const PHISHING_DATABASE_MAX_BODY_BYTES: usize = 8 * 1024 * 1024; +const OUTBOUND_FETCH_DEFAULT_BYTES: usize = 2 * 1024 * 1024; +const OUTBOUND_FETCH_MAX_BYTES: usize = 8 * 1024 * 1024; +const OUTBOUND_FETCH_MAX_REDIRECTS: usize = 3; const PHISHING_DATABASE_ALLOWED_HOSTS: &[&str] = &["raw.githubusercontent.com", "phish.co.za"]; fn phishing_database_default_feed_id() -> String { @@ -581,6 +585,32 @@ struct ErrorBody { error: String, } +#[derive(Deserialize)] +struct OutboundFetchRequest { + url: String, + #[serde(default = "outbound_fetch_default_bytes")] + max_bytes: usize, +} + +#[derive(Serialize)] +struct OutboundFetchResponse { + status: u16, + content_type: String, + final_url: String, + body_base64: String, + redirects: usize, +} + +#[derive(Serialize)] +struct OutboundFetchError { + code: &'static str, + error: &'static str, +} + +fn outbound_fetch_default_bytes() -> usize { + OUTBOUND_FETCH_DEFAULT_BYTES +} + pub fn build_app(state: AppState) -> Router { let max_body_bytes = state.max_body_bytes; Router::new() @@ -602,6 +632,7 @@ pub fn build_app(state: AppState) -> Router { .route("/api/kpis", get(kpis)) .route("/api/signatures", get(list_signatures)) .route("/api/evaluate", post(evaluate_request)) + .route("/api/outbound/fetch", post(outbound_fetch)) .route("/metrics", get(metrics)) .route( "/api/commercial/license", @@ -638,6 +669,203 @@ pub fn build_app(state: AppState) -> Router { .with_state(state) } +fn outbound_fetch_error(status: StatusCode, code: &'static str, message: &'static str) -> Response { + ( + status, + Json(OutboundFetchError { + code, + error: message, + }), + ) + .into_response() +} + +fn outbound_content_type_allowed(value: &str) -> bool { + let essence = value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + essence.starts_with("text/") + || matches!( + essence.as_str(), + "application/json" | "application/xml" | "application/xhtml+xml" | "application/pdf" + ) +} + +async fn outbound_fetch( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Response { + if !admin_authorized(&state, &headers) { + return outbound_fetch_error( + StatusCode::UNAUTHORIZED, + "unauthorized", + "missing or invalid X-Admin-Token", + ); + } + if request.max_bytes == 0 || request.max_bytes > OUTBOUND_FETCH_MAX_BYTES { + return outbound_fetch_error( + StatusCode::BAD_REQUEST, + "invalid_max_bytes", + "max_bytes is outside the supported range", + ); + } + match tokio::time::timeout( + Duration::from_secs(20), + outbound_fetch_inner(&state, request.url, request.max_bytes), + ) + .await + { + Ok(Ok(response)) => Json(response).into_response(), + Ok(Err((status, code, message))) => outbound_fetch_error(status, code, message), + Err(_) => outbound_fetch_error( + StatusCode::GATEWAY_TIMEOUT, + "fetch_timeout", + "upstream fetch timed out", + ), + } +} + +async fn outbound_fetch_inner( + state: &AppState, + initial_url: String, + max_bytes: usize, +) -> Result { + use futures_util::StreamExt; + + let mut url = reqwest::Url::parse(&initial_url).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "invalid_url", + "url must be an absolute HTTPS URL", + ) + })?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + { + return Err(( + StatusCode::BAD_REQUEST, + "invalid_url", + "url must be an absolute HTTPS URL without credentials", + )); + } + url.set_fragment(None); + + for redirects in 0..=OUTBOUND_FETCH_MAX_REDIRECTS { + state.assert_outbound(url.as_str()).await.map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "destination_denied", + "destination policy denied the URL", + ) + })?; + let response = state.http.get(url.clone()).send().await.map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "upstream_request_failed", + "upstream request failed", + ) + })?; + + if response.status().is_redirection() { + if redirects == OUTBOUND_FETCH_MAX_REDIRECTS { + return Err(( + StatusCode::BAD_GATEWAY, + "too_many_redirects", + "upstream redirect limit exceeded", + )); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or(( + StatusCode::BAD_GATEWAY, + "invalid_redirect", + "upstream redirect is missing a valid Location header", + ))?; + url = url.join(location).map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "invalid_redirect", + "upstream redirect Location is invalid", + ) + })?; + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return Err(( + StatusCode::BAD_GATEWAY, + "unsafe_redirect", + "upstream redirect target is not permitted", + )); + } + url.set_fragment(None); + continue; + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .filter(|value| value.len() <= 256) + .ok_or(( + StatusCode::BAD_GATEWAY, + "unsupported_content_type", + "upstream Content-Type is missing or unsupported", + ))? + .to_string(); + if !outbound_content_type_allowed(&content_type) { + return Err(( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "unsupported_content_type", + "upstream Content-Type is missing or unsupported", + )); + } + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + "response_too_large", + "upstream response exceeds max_bytes", + )); + } + let status = response.status().as_u16(); + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "upstream_body_failed", + "upstream response body failed", + ) + })?; + if body.len().saturating_add(chunk.len()) > max_bytes { + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + "response_too_large", + "upstream response exceeds max_bytes", + )); + } + body.extend_from_slice(&chunk); + } + return Ok(OutboundFetchResponse { + status, + content_type, + final_url: url.to_string(), + body_base64: BASE64.encode(body), + redirects, + }); + } + unreachable!("redirect loop exits at the configured bound") +} + pub fn export_events_ndjson(events: &[SecurityEvent]) -> Result { let mut out = String::new(); for event in events { @@ -4119,6 +4347,44 @@ mod tests { assert_eq!(rate_limit_step(160, 100, 2, 2, 60), (true, 160, 1)); } + #[test] + fn outbound_fetch_accepts_only_document_content_types() { + assert!(outbound_content_type_allowed("text/html; charset=utf-8")); + assert!(outbound_content_type_allowed("application/xhtml+xml")); + assert!(outbound_content_type_allowed("application/pdf")); + assert!(!outbound_content_type_allowed("application/octet-stream")); + assert!(!outbound_content_type_allowed("image/svg+xml")); + } + + #[tokio::test] + async fn outbound_fetch_requires_auth_and_https_before_dns() { + let app = build_app(AppState::seeded(Some("secret".to_string()))); + let payload = serde_json::json!({"url": "http://example.com/privacy"}); + + let unauthorized = app_request( + &app, + json_request(Method::POST, "/api/outbound/fetch", None, &payload), + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + let unauthorized_body: serde_json::Value = json_body(unauthorized).await; + assert_eq!(unauthorized_body["code"], "unauthorized"); + + let insecure = app_request( + &app, + json_request( + Method::POST, + "/api/outbound/fetch", + Some("secret"), + &payload, + ), + ) + .await; + assert_eq!(insecure.status(), StatusCode::BAD_REQUEST); + let insecure_body: serde_json::Value = json_body(insecure).await; + assert_eq!(insecure_body["code"], "invalid_url"); + } + #[tokio::test] async fn gateway_rate_limits_per_client_ip() { let app = build_app(AppState::seeded(None).with_rate_limit(2, 60)); From 3f05a8f74acabc546853e359b907e4aee7db343c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 21:35:02 +0900 Subject: [PATCH 2/4] feat(security): route browser DNS and HTTPS through Wardnet --- Cargo.lock | 68 ++++++++ Cargo.toml | 3 + README.md | 8 + deploy/kubernetes/waf-ids-ai-soc.yaml | 22 +++ docs/camoufox-egress.md | 49 ++++++ src/credentials.rs | 29 +++- src/egress_dns.rs | 195 +++++++++++++++++++++ src/lib.rs | 236 +++++++++++++++++++++++++- 8 files changed, 599 insertions(+), 11 deletions(-) create mode 100644 docs/camoufox-egress.md create mode 100644 src/egress_dns.rs 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 e78ea0bb..1b7cf4c0 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. @@ -120,6 +125,9 @@ and `redirects`. Wardnet follows at most three HTTPS redirects, revalidates and pins DNS at every hop, disables ambient proxies, accepts document content types, and caps `max_bytes` at 8 MiB. Errors return stable `code` and safe `error` fields. +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..abe19e53 --- /dev/null +++ b/src/egress_dns.rs @@ -0,0 +1,195 @@ +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 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 }; + if let Some(response) = answer(&state_udp, &packet[..length]).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()) + } + } + + #[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() + ]) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 47da8342..6bc89d9c 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, } } @@ -230,7 +270,10 @@ impl AppState { /// Blocking OS DNS runs on `spawn_blocking` with a bounded timeout so a /// hung resolver cannot starve Tokio workers. Successful evaluations are /// recorded on the pin board the HTTP clients use for connect-time DNS. - async fn assert_outbound(&self, url: &str) -> Result<(), String> { + async fn resolve_outbound( + &self, + url: &str, + ) -> Result { let policy = self.destination.clone(); let resolver = Arc::clone(&self.resolver); let url = url.to_string(); @@ -242,7 +285,11 @@ impl AppState { .map_err(|_| "destination DNS timed out".to_string())? .map_err(|_| "destination evaluation cancelled".to_string())??; self.pins.record(&decision.host, &decision.ips); - Ok(()) + Ok(decision) + } + + async fn assert_outbound(&self, url: &str) -> Result<(), String> { + self.resolve_outbound(url).await.map(|_| ()) } /// Enable per-client-IP rate limiting: at most `limit` gateway requests per @@ -261,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; @@ -665,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, @@ -3875,6 +4032,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 @@ -3952,17 +4110,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); @@ -4385,6 +4571,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)); From dd96217cedf118f4a1696ddefaef12cf18688ca8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 22:00:58 +0900 Subject: [PATCH 3/4] fix(security): isolate fetch DNS pins per request --- README.md | 5 +++- docs/research/outbound-egress-security.md | 34 +++++++++++++++++++++++ src/lib.rs | 20 ++++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 docs/research/outbound-egress-security.md diff --git a/README.md b/README.md index e78ea0bb..738fea36 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,10 @@ curl -X POST http://127.0.0.1:8080/api/outbound/fetch \ The JSON response contains `status`, `content_type`, `final_url`, `body_base64`, and `redirects`. Wardnet follows at most three HTTPS redirects, revalidates and pins DNS at every hop, disables ambient proxies, accepts document content types, -and caps `max_bytes` at 8 MiB. Errors return stable `code` and safe `error` fields. +and caps `max_bytes` at 8 MiB. A missing, malformed, or unsupported +`Content-Type` is rejected rather than inferred from bytes. Errors return stable +`code` and safe `error` fields. Security research grounding is recorded in +[`docs/research/outbound-egress-security.md`](docs/research/outbound-egress-security.md). Add a blocking route: diff --git a/docs/research/outbound-egress-security.md b/docs/research/outbound-egress-security.md new file mode 100644 index 00000000..be04ccaa --- /dev/null +++ b/docs/research/outbound-egress-security.md @@ -0,0 +1,34 @@ +# Outbound egress security research + +Wardnet's fetch boundary follows two research-backed constraints: destination +authorization must cover resolved addresses, and the authorized address set must +remain bound to the subsequent connection. URL-string filtering alone does not +cover DNS rebinding or redirects. + +Jackson et al. describe DNS rebinding as a firewall-circumvention technique and +evaluate policy-based pinning and hostname authorization as deployable defenses. +Wardnet therefore evaluates every resolved address, rejects denied address +classes, and gives each fetch hop a request-local DNS pin board so the HTTP +connection cannot perform a second, different resolution. + +Jabiyev et al. show that SSRF defenses are bypassed when validation and the +actual network request are separated, including through changing DNS answers. +Wardnet keeps URL parsing, address-class policy, redirect validation, and the +connect-time address set inside one egress owner. Redirects are disabled in the +HTTP client and followed manually only after a new policy evaluation. + +## References + +Jackson, C., Barth, A., Bortz, A., Shao, W., & Boneh, D. (2009). Protecting +browsers from DNS rebinding attacks. *ACM Transactions on the Web, 3*(1), 1–26. +https://doi.org/10.1145/1462148.1462150. Author publication +page and manuscript: https://cs.stanford.edu/people/dabo/pubs/abstracts/dnsrebind.html + +Jabiyev, B., Mirzaei, O., Kharraz, A., & Kirda, E. (2021). Preventing server-side +request forgery attacks. In *Proceedings of the 36th ACM/SIGAPP Symposium on +Applied Computing* (pp. 1626–1635). +https://doi.org/10.1145/3412841.3442036. Author-hosted manuscript: +https://theseclab.org/publications/sac21.pdf + +The papers are linked rather than copied because redistribution rights for the +publisher versions were not established for this repository. diff --git a/src/lib.rs b/src/lib.rs index 47da8342..1b0ca33f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -230,7 +230,10 @@ impl AppState { /// Blocking OS DNS runs on `spawn_blocking` with a bounded timeout so a /// hung resolver cannot starve Tokio workers. Successful evaluations are /// recorded on the pin board the HTTP clients use for connect-time DNS. - async fn assert_outbound(&self, url: &str) -> Result<(), String> { + async fn resolve_outbound( + &self, + url: &str, + ) -> Result { let policy = self.destination.clone(); let resolver = Arc::clone(&self.resolver); let url = url.to_string(); @@ -242,7 +245,11 @@ impl AppState { .map_err(|_| "destination DNS timed out".to_string())? .map_err(|_| "destination evaluation cancelled".to_string())??; self.pins.record(&decision.host, &decision.ips); - Ok(()) + Ok(decision) + } + + async fn assert_outbound(&self, url: &str) -> Result<(), String> { + self.resolve_outbound(url).await.map(|_| ()) } /// Enable per-client-IP rate limiting: at most `limit` gateway requests per @@ -757,14 +764,19 @@ async fn outbound_fetch_inner( url.set_fragment(None); for redirects in 0..=OUTBOUND_FETCH_MAX_REDIRECTS { - state.assert_outbound(url.as_str()).await.map_err(|_| { + let decision = state.resolve_outbound(url.as_str()).await.map_err(|_| { ( StatusCode::BAD_REQUEST, "destination_denied", "destination policy denied the URL", ) })?; - let response = state.http.get(url.clone()).send().await.map_err(|_| { + // A request-local pin board prevents a concurrent evaluation of the same + // hostname from replacing the addresses between policy and connect. + let request_pins = Arc::new(destination::DestinationPins::default()); + request_pins.record(&decision.host, &decision.ips); + let request_http = outbound_http_client(request_pins); + let response = request_http.get(url.clone()).send().await.map_err(|_| { ( StatusCode::BAD_GATEWAY, "upstream_request_failed", From ab952e21f03ec541336053f129e7854ea54915cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 22:04:20 +0900 Subject: [PATCH 4/4] fix(dns): bound concurrent UDP query handling --- src/egress_dns.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/src/egress_dns.rs b/src/egress_dns.rs index abe19e53..530dc103 100644 --- a/src/egress_dns.rs +++ b/src/egress_dns.rs @@ -82,15 +82,24 @@ 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 }; - if let Some(response) = answer(&state_udp, &packet[..length]).await { - let _ = udp.send_to(&response, peer).await; - } + 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; + } + }); } } } @@ -142,6 +151,26 @@ mod tests { } } + 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) @@ -192,4 +221,29 @@ mod tests { ]) ); } + + #[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(); + } }