Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions deploy/kubernetes/waf-ids-ai-soc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
49 changes: 49 additions & 0 deletions docs/camoufox-egress.md
Original file line number Diff line number Diff line change
@@ -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: <wardnet-dns-service-ip> (UDP and TCP port 53)
HTTP/HTTPS proxy: http://<wardnet-internal-ip>:8080
Proxy username: wardnet
Proxy password: <egress_proxy_token from KV>
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.
29 changes: 26 additions & 3 deletions src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -75,6 +76,7 @@ impl CredentialRegistry {
env_admin_token: Option<String>,
env_admin_tokens: Option<String>,
env_control_plane_url: Option<String>,
env_egress_proxy_token: Option<String>,
) -> Result<Self, String> {
let mut values = HashMap::new();
let mut from_file = false;
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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());
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -243,6 +263,7 @@ mod tests {
Some("ignored".to_string()),
Some("envtok:bob".to_string()),
None,
None,
)
.unwrap();
assert_eq!(registry.source(), CredentialSource::File);
Expand Down Expand Up @@ -270,6 +291,7 @@ mod tests {
Some("env-secret".to_string()),
None,
None,
None,
)
.unwrap();
assert_eq!(registry.source(), CredentialSource::Env);
Expand All @@ -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);
}
Expand Down
Loading
Loading