From 501122088d77989834e8d796e1a95f10cc61b949 Mon Sep 17 00:00:00 2001 From: Ho Geun Choi Date: Wed, 22 Jul 2026 11:28:13 +0900 Subject: [PATCH] feat(ebpf): filter IPv6 egress (close the v6 bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel guard only enforced IPv4 — non-IPv4 packets passed, so a tool could exfiltrate over IPv6 and defeat the "unbypassable" egress claim. Now both families are default-deny. eBPF: new ALLOW6 map (u128 key = u128::from(Ipv6Addr)); the program branches on IP version and, for v6, loads the 16-byte destination as two u64 halves (64-bit ops keep the verifier happy). Loopback (::1) and infra prefixes (link-local fe80::/10, multicast ff00::/8) always pass, mirroring the v4 loopback exception. Control plane: `allow` / `allow-domain` / the admin socket / policy lowering all accept v4 and v6. Domain resolution injects both families into ALLOW / ALLOW6; allow_insert/remove/list are IpAddr-based. Tests: pasu-rules v6 lowering; admin v6 parse; an IPv6 kernel E2E (self-skips without v6 connectivity — GitHub runners have none — but exercises the v6 path on a v6-capable host; the v4 E2E already proves the updated program verifies). Signed-off-by: Ho Geun Choi --- CHANGELOG.md | 5 ++ README.en.md | 9 ++- README.md | 2 +- crates/pasu-cli/src/main.rs | 6 +- crates/pasu-daemon/src/main.rs | 6 +- crates/pasu-ebpf/src/main.rs | 47 ++++++++--- crates/pasu-egress/src/admin.rs | 37 +++++---- crates/pasu-egress/src/guard.rs | 104 ++++++++++++++++--------- crates/pasu-egress/src/main.rs | 21 +++-- crates/pasu-egress/tests/egress_e2e.rs | 39 ++++++++++ crates/pasu-rules/src/lib.rs | 23 +++++- docs/deployment.md | 4 +- 12 files changed, 227 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 005ce5d..83afb50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ its first tagged release. ## [Unreleased] ### Added +- **IPv6 kernel egress filtering** — the eBPF guard now enforces default-deny on + IPv6 too (new `ALLOW6` map, v6 destination parsing), closing the bypass where + a tool could exfiltrate over IPv6. Loopback (`::1`) and infrastructure prefixes + (link-local `fe80::/10`, multicast `ff00::/8`) always pass. `allow`/`allow-domain`, + the admin socket, and policy lowering all accept v4 and v6. - **Proxy parse benchmarks + evidence-backed metrics** — criterion micro-benchmarks for the per-response guard cost (`extract` per provider + SSE reassembly) alongside the existing policy bench; the README metrics diff --git a/README.en.md b/README.en.md index 768ed4d..ad44fbc 100644 --- a/README.en.md +++ b/README.en.md @@ -181,10 +181,11 @@ sudo pasu-daemon --policy rules.yaml --cgroup-path /sys/fs/cgroup/my-agent sudo pasu-egress --cgroup-path /sys/fs/cgroup/my-agent --allow-domain api.openai.com ``` -Allow rules with an IPv4 become static entries, and exact hostnames are resolved -(and re-resolved). Suffix patterns (`.openai.com`) can't be lowered to the kernel -yet and are only reported — DNS-response sniffing will close that. The kernel -side is default-deny, so lowering is only ever *narrower* than the policy. +Allow rules with an IPv4/IPv6 literal become static entries, and exact hostnames +are resolved (and re-resolved, both families). Suffix patterns (`.openai.com`) +can't be lowered to the kernel yet and are only reported — DNS-response sniffing +will close that. The kernel side is default-deny for **both v4 and v6**, so +lowering is only ever *narrower* than the policy. Add `--admin-socket /run/pasu.sock` to inspect and edit the live guard without a restart (this is what the UI talks to): diff --git a/README.md b/README.md index 66c35bc..7c329bd 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ sudo pasu-daemon --policy rules.yaml --cgroup-path /sys/fs/cgroup/my-agent sudo pasu-egress --cgroup-path /sys/fs/cgroup/my-agent --allow-domain api.openai.com ``` -IPv4 allow는 정적 항목이 되고, 정확한 호스트명은 주기적으로 재해석됩니다. 접미 패턴(`.openai.com`)은 아직 커널로 내려가지 못해 리포트만 남깁니다 — DNS 응답 스니핑이 들어오면 해결됩니다. 커널은 기본 차단이라, 변환 결과는 정책보다 좁아질 뿐 넓어지지 않습니다. +IPv4/IPv6 리터럴 allow는 정적 항목이 되고, 정확한 호스트명은 주기적으로 재해석됩니다(양 계열). 접미 패턴(`.openai.com`)은 아직 커널로 내려가지 못해 리포트만 남깁니다 — DNS 응답 스니핑이 들어오면 해결됩니다. 커널은 **v4·v6 모두 기본 차단**이라, 변환 결과는 정책보다 좁아질 뿐 넓어지지 않습니다. `--admin-socket /run/pasu.sock`을 붙이면 재시작 없이 실행 중인 가드를 들여다보고 수정할 수 있습니다 (UI도 이 소켓을 씁니다). diff --git a/crates/pasu-cli/src/main.rs b/crates/pasu-cli/src/main.rs index 726fc5a..170ecb4 100644 --- a/crates/pasu-cli/src/main.rs +++ b/crates/pasu-cli/src/main.rs @@ -58,19 +58,23 @@ fn lower(args: &RunArgs, cgroup_path: PathBuf) -> anyhow::Result { for ip in &allowlist.ips { println!(" kernel allow ip {ip}"); } + for ip6 in &allowlist.ips6 { + println!(" kernel allow ip {ip6}"); + } for d in &allowlist.domains { println!(" kernel allow domain {d}"); } for s in &allowlist.skipped { println!(" hook-layer only {} ({})", s.rule, s.reason); } - if allowlist.ips.is_empty() && allowlist.domains.is_empty() { + if allowlist.ips.is_empty() && allowlist.ips6.is_empty() && allowlist.domains.is_empty() { println!(" (no kernel-expressible allow rules: everything is dropped)"); } Ok(GuardConfig { cgroup_path, allow: allowlist.ips, + allow6: allowlist.ips6, allow_domain: allowlist.domains, refresh_secs: args.refresh_secs, admin_socket: args.admin_socket.clone(), diff --git a/crates/pasu-daemon/src/main.rs b/crates/pasu-daemon/src/main.rs index 9ee6913..132e03b 100644 --- a/crates/pasu-daemon/src/main.rs +++ b/crates/pasu-daemon/src/main.rs @@ -40,19 +40,23 @@ fn load(opt: Opt) -> anyhow::Result { for ip in &allowlist.ips { println!(" kernel allow ip {ip}"); } + for ip6 in &allowlist.ips6 { + println!(" kernel allow ip {ip6}"); + } for d in &allowlist.domains { println!(" kernel allow domain {d}"); } for s in &allowlist.skipped { println!(" hook-layer only {} ({})", s.rule, s.reason); } - if allowlist.ips.is_empty() && allowlist.domains.is_empty() { + if allowlist.ips.is_empty() && allowlist.ips6.is_empty() && allowlist.domains.is_empty() { println!(" (no kernel-expressible allow rules: everything is dropped)"); } Ok(GuardConfig { cgroup_path: opt.cgroup_path, allow: allowlist.ips, + allow6: allowlist.ips6, allow_domain: allowlist.domains, refresh_secs: opt.refresh_secs, admin_socket: opt.admin_socket, diff --git a/crates/pasu-ebpf/src/main.rs b/crates/pasu-ebpf/src/main.rs index 51ad3e7..42f20db 100644 --- a/crates/pasu-ebpf/src/main.rs +++ b/crates/pasu-ebpf/src/main.rs @@ -8,13 +8,16 @@ use aya_ebpf::{ }; use aya_log_ebpf::info; -// M1: default-deny allowlist. IPv4 destinations allowed to egress (host byte order) -// are injected from user space (control plane); anything else is dropped. +// default-deny allowlist. Destinations allowed to egress are injected from user +// space (control plane); anything else is dropped. IPv4 keys are host-order u32; +// IPv6 keys are the 16-byte address as a big-endian u128 (matches u128::from(Ipv6Addr)). // // Because unlisted traffic is dropped, this MUST be attached to a dedicated cgroup, // NEVER the root cgroup (that would cut the host's own egress, including SSH). #[map] static ALLOW: HashMap = HashMap::with_max_entries(1024, 0); +#[map] +static ALLOW6: HashMap = HashMap::with_max_entries(1024, 0); #[cgroup_skb] pub fn pasu_egress(ctx: SkBuffContext) -> i32 { @@ -27,13 +30,18 @@ pub fn pasu_egress(ctx: SkBuffContext) -> i32 { fn try_pasu_egress(ctx: SkBuffContext) -> Result { // cgroup_skb egress: the packet begins at the IPv4 header (L3, no ethernet frame). // byte 0 = version/IHL; bytes 16..20 = destination address. - let ver_ihl: u8 = ctx.load(0).map_err(|_| ())?; - if ver_ihl >> 4 != 4 { - // Non-IPv4 (IPv6, etc.) passes — M1 scope is IPv4. (IPv6 egress control is - // out of scope for now; documented as a known gap.) - return Ok(1); + let version: u8 = ctx.load::(0).map_err(|_| ())? >> 4; + match version { + 4 => try_v4(&ctx), + 6 => try_v6(&ctx), + // Neither IPv4 nor IPv6 (ARP already handled below L3; anything else) → + // drop under default-deny (fail-closed). + _ => Ok(0), } +} +fn try_v4(ctx: &SkBuffContext) -> Result { + // IPv4 header (L3, no ethernet frame): bytes 16..20 = destination address. let dst_be: u32 = ctx.load(16).map_err(|_| ())?; let dst = u32::from_be(dst_be); // host byte order, matches u32::from(Ipv4Addr) @@ -42,12 +50,33 @@ fn try_pasu_egress(ctx: SkBuffContext) -> Result { if dst >> 24 == 127 { return Ok(1); } - if unsafe { ALLOW.get(&dst) }.is_some() { return Ok(1); // allowlisted → pass } + info!(ctx, "pasu: dropped IPv4 egress (dst not in ALLOW map)"); + Ok(0) // default-deny → drop +} + +fn try_v6(ctx: &SkBuffContext) -> Result { + // IPv6 header (L3): bytes 24..40 = 16-byte destination address (network order). + // Load as two u64 halves to keep the verifier happy (64-bit ops only). + let hi = u64::from_be(ctx.load::(24).map_err(|_| ())?); + let lo = u64::from_be(ctx.load::(32).map_err(|_| ())?); + + // Infrastructure prefixes always pass — dropping them breaks basic v6 + // operation (NDP, on-link), same spirit as the v4 loopback exception: + // ::1 loopback + // fe80::/10 link-local (NDP, router) + // ff00::/8 multicast (NDP solicitations, etc.) + if (hi == 0 && lo == 1) || (hi >> 54 == 0x3FA) || (hi >> 56 == 0xff) { + return Ok(1); + } - info!(&ctx, "pasu: dropped egress (dst not in ALLOW map)"); + let key: u128 = ((hi as u128) << 64) | (lo as u128); // == u128::from(Ipv6Addr) + if unsafe { ALLOW6.get(&key) }.is_some() { + return Ok(1); // allowlisted → pass + } + info!(ctx, "pasu: dropped IPv6 egress (dst not in ALLOW6 map)"); Ok(0) // default-deny → drop } diff --git a/crates/pasu-egress/src/admin.rs b/crates/pasu-egress/src/admin.rs index c28a63b..301259b 100644 --- a/crates/pasu-egress/src/admin.rs +++ b/crates/pasu-egress/src/admin.rs @@ -12,7 +12,7 @@ //! The socket only mediates requests; the eBPF `ALLOW` map is owned by the guard //! loop, which applies commands one at a time (no shared mutable eBPF state). -use std::net::Ipv4Addr; +use std::net::IpAddr; use tokio::sync::oneshot; @@ -20,12 +20,12 @@ use tokio::sync::oneshot; #[derive(Debug, PartialEq)] pub enum Request { Status, - Allow(Ipv4Addr), - Deny(Ipv4Addr), + Allow(IpAddr), + Deny(IpAddr), } -/// Parse one request line. Case-insensitive verb; a single IPv4 argument for -/// `allow`/`deny`. +/// Parse one request line. Case-insensitive verb; a single IP (v4 or v6) +/// argument for `allow`/`deny`. pub fn parse_request(line: &str) -> Result { let mut parts = line.split_whitespace(); let verb = parts.next().unwrap_or_default().to_ascii_lowercase(); @@ -34,10 +34,10 @@ pub fn parse_request(line: &str) -> Result { "allow" | "deny" => { let arg = parts .next() - .ok_or_else(|| format!("{verb}: missing "))?; - let ip: Ipv4Addr = arg + .ok_or_else(|| format!("{verb}: missing "))?; + let ip: IpAddr = arg .parse() - .map_err(|_| format!("{verb}: invalid ipv4 `{arg}`"))?; + .map_err(|_| format!("{verb}: invalid ip `{arg}`"))?; Ok(if verb == "allow" { Request::Allow(ip) } else { @@ -64,8 +64,8 @@ pub struct Status { /// A command handed to the guard loop, with a channel for its reply. pub enum Command { Status(oneshot::Sender), - Allow(Ipv4Addr, oneshot::Sender>), - Deny(Ipv4Addr, oneshot::Sender>), + Allow(IpAddr, oneshot::Sender>), + Deny(IpAddr, oneshot::Sender>), } #[cfg(test)] @@ -82,11 +82,23 @@ mod tests { fn parses_allow_deny() { assert_eq!( parse_request("allow 1.2.3.4").unwrap(), - Request::Allow(Ipv4Addr::new(1, 2, 3, 4)) + Request::Allow("1.2.3.4".parse().unwrap()) ); assert_eq!( parse_request("DENY 10.0.0.1").unwrap(), - Request::Deny(Ipv4Addr::new(10, 0, 0, 1)) + Request::Deny("10.0.0.1".parse().unwrap()) + ); + } + + #[test] + fn parses_ipv6() { + assert_eq!( + parse_request("allow 2606:4700:4700::1111").unwrap(), + Request::Allow("2606:4700:4700::1111".parse().unwrap()) + ); + assert_eq!( + parse_request("deny ::1").unwrap(), + Request::Deny("::1".parse().unwrap()) ); } @@ -94,7 +106,6 @@ mod tests { fn rejects_bad_input() { assert!(parse_request("allow").is_err()); // missing arg assert!(parse_request("allow not-an-ip").is_err()); - assert!(parse_request("allow ::1").is_err()); // ipv4 only assert!(parse_request("bogus").is_err()); assert!(parse_request("").is_err()); } diff --git a/crates/pasu-egress/src/guard.rs b/crates/pasu-egress/src/guard.rs index f70445f..a063b52 100644 --- a/crates/pasu-egress/src/guard.rs +++ b/crates/pasu-egress/src/guard.rs @@ -6,7 +6,7 @@ //! `pasu-daemon`) can run the same guard from different policy sources. use std::future::Future; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::path::PathBuf; use std::time::Duration; @@ -29,6 +29,8 @@ pub struct GuardConfig { pub cgroup_path: PathBuf, /// Static IPv4 allow entries. pub allow: Vec, + /// Destination IPv6 addresses allowed to egress (static allow entries). + pub allow6: Vec, /// Domains whose resolved IPv4s are allowed (re-resolved periodically). pub allow_domain: Vec, /// Domain re-resolution interval, seconds. @@ -37,15 +39,11 @@ pub struct GuardConfig { pub admin_socket: Option, } -/// Resolve a domain to its IPv4 addresses (best-effort; empty vec on failure). -async fn resolve_v4(domain: &str) -> Vec { +/// Resolve a domain to its IP addresses (best-effort; empty on failure). Both +/// families — v4 goes to the ALLOW map, v6 to ALLOW6. +async fn resolve(domain: &str) -> Vec { match tokio::net::lookup_host(format!("{domain}:443")).await { - Ok(addrs) => addrs - .filter_map(|sa| match sa.ip() { - IpAddr::V4(v4) => Some(v4), - IpAddr::V6(_) => None, - }) - .collect(), + Ok(addrs) => addrs.map(|sa| sa.ip()).collect(), Err(e) => { warn!("resolve {domain} failed: {e}"); Vec::new() @@ -53,49 +51,75 @@ async fn resolve_v4(domain: &str) -> Vec { } } -/// Resolve every domain and inject the resulting IPv4s into the ALLOW map. +/// Resolve every domain and inject the resulting IPs into the ALLOW/ALLOW6 maps. async fn refresh_domains(ebpf: &mut aya::Ebpf, domains: &[String]) -> anyhow::Result<()> { let mut ips = Vec::new(); for d in domains { - ips.extend(resolve_v4(d).await); + ips.extend(resolve(d).await); } - let mut allow: AyaHashMap<_, u32, u8> = - AyaHashMap::try_from(ebpf.map_mut("ALLOW").context("ALLOW map not found")?)?; for ip in ips { - allow.insert(u32::from(ip), 1u8, 0)?; + allow_insert(ebpf, ip)?; } Ok(()) } -fn allow_insert(ebpf: &mut aya::Ebpf, ip: Ipv4Addr) -> anyhow::Result<()> { - let mut allow: AyaHashMap<_, u32, u8> = - AyaHashMap::try_from(ebpf.map_mut("ALLOW").context("ALLOW map not found")?)?; - allow.insert(u32::from(ip), 1u8, 0)?; +fn allow_insert(ebpf: &mut aya::Ebpf, ip: IpAddr) -> anyhow::Result<()> { + match ip { + IpAddr::V4(v4) => { + let mut allow: AyaHashMap<_, u32, u8> = + AyaHashMap::try_from(ebpf.map_mut("ALLOW").context("ALLOW map not found")?)?; + allow.insert(u32::from(v4), 1u8, 0)?; + } + IpAddr::V6(v6) => { + let mut allow: AyaHashMap<_, u128, u8> = + AyaHashMap::try_from(ebpf.map_mut("ALLOW6").context("ALLOW6 map not found")?)?; + allow.insert(u128::from(v6), 1u8, 0)?; + } + } Ok(()) } -fn allow_remove(ebpf: &mut aya::Ebpf, ip: Ipv4Addr) -> anyhow::Result<()> { - let mut allow: AyaHashMap<_, u32, u8> = - AyaHashMap::try_from(ebpf.map_mut("ALLOW").context("ALLOW map not found")?)?; - allow.remove(&u32::from(ip))?; +fn allow_remove(ebpf: &mut aya::Ebpf, ip: IpAddr) -> anyhow::Result<()> { + match ip { + IpAddr::V4(v4) => { + let mut allow: AyaHashMap<_, u32, u8> = + AyaHashMap::try_from(ebpf.map_mut("ALLOW").context("ALLOW map not found")?)?; + allow.remove(&u32::from(v4))?; + } + IpAddr::V6(v6) => { + let mut allow: AyaHashMap<_, u128, u8> = + AyaHashMap::try_from(ebpf.map_mut("ALLOW6").context("ALLOW6 map not found")?)?; + allow.remove(&u128::from(v6))?; + } + } Ok(()) } -/// Read the current ALLOW map keys as sorted IPv4 strings. +/// Read the current ALLOW + ALLOW6 map keys as sorted IP strings. fn allow_list(ebpf: &aya::Ebpf) -> Vec { - let Some(map) = ebpf.map("ALLOW") else { - return Vec::new(); - }; - let Ok(allow): Result, _> = AyaHashMap::try_from(map) else { - return Vec::new(); - }; - let mut ips: Vec = allow - .keys() - .filter_map(Result::ok) - .map(Ipv4Addr::from) - .collect(); - ips.sort(); - ips.into_iter().map(|ip| ip.to_string()).collect() + let mut out: Vec = Vec::new(); + if let Some(map) = ebpf.map("ALLOW") { + if let Ok(allow) = >::try_from(map) { + out.extend( + allow + .keys() + .filter_map(Result::ok) + .map(|k| IpAddr::from(Ipv4Addr::from(k))), + ); + } + } + if let Some(map) = ebpf.map("ALLOW6") { + if let Ok(allow) = >::try_from(map) { + out.extend( + allow + .keys() + .filter_map(Result::ok) + .map(|k| IpAddr::from(Ipv6Addr::from(k))), + ); + } + } + out.sort(); + out.into_iter().map(|ip| ip.to_string()).collect() } /// Accept connections on the admin socket and forward parsed requests to the @@ -210,11 +234,15 @@ impl Guard { } } - // Control plane → eBPF: inject static IPs into the ALLOW map. + // Control plane → eBPF: inject static IPs into the ALLOW / ALLOW6 maps. for ip in &cfg.allow { - allow_insert(&mut ebpf, *ip)?; + allow_insert(&mut ebpf, IpAddr::V4(*ip))?; println!("allowlist += {ip}"); } + for ip6 in &cfg.allow6 { + allow_insert(&mut ebpf, IpAddr::V6(*ip6))?; + println!("allowlist += {ip6}"); + } if !cfg.allow_domain.is_empty() { refresh_domains(&mut ebpf, &cfg.allow_domain).await?; for d in &cfg.allow_domain { diff --git a/crates/pasu-egress/src/main.rs b/crates/pasu-egress/src/main.rs index 59ae2a4..afdbcb6 100644 --- a/crates/pasu-egress/src/main.rs +++ b/crates/pasu-egress/src/main.rs @@ -1,4 +1,4 @@ -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use anyhow::Context as _; use clap::Parser; @@ -15,9 +15,9 @@ struct Opt { /// own egress (SSH included). #[clap(short, long)] cgroup_path: Option, - /// Destination IPv4 allowed to egress (repeatable). Everything else is dropped. + /// Destination IP (v4 or v6) allowed to egress (repeatable). Everything else is dropped. #[clap(short, long = "allow")] - allow: Vec, + allow: Vec, /// Domain whose resolved IPv4 addresses are allowed (repeatable). Best-effort, /// control-plane resolution (precise DNS-response sniffing is future work, M2b). #[clap(short = 'd', long = "allow-domain")] @@ -41,7 +41,7 @@ struct Config { /// Dedicated cgroup v2 path (never the root cgroup). cgroup_path: std::path::PathBuf, #[serde(default)] - allow: Vec, + allow: Vec, #[serde(default)] allow_domain: Vec, #[serde(default = "default_refresh_secs")] @@ -73,9 +73,18 @@ impl Opt { impl From for GuardConfig { fn from(cfg: Config) -> Self { + let mut allow: Vec = Vec::new(); + let mut allow6: Vec = Vec::new(); + for ip in cfg.allow { + match ip { + IpAddr::V4(v4) => allow.push(v4), + IpAddr::V6(v6) => allow6.push(v6), + } + } GuardConfig { cgroup_path: cfg.cgroup_path, - allow: cfg.allow, + allow, + allow6, allow_domain: cfg.allow_domain, refresh_secs: cfg.refresh_secs, admin_socket: cfg.admin_socket, @@ -106,7 +115,7 @@ mod tests { cfg.cgroup_path, std::path::PathBuf::from("/sys/fs/cgroup/pasu-agent") ); - assert_eq!(cfg.allow, vec![Ipv4Addr::new(1, 0, 0, 1)]); + assert_eq!(cfg.allow, vec![IpAddr::V4(Ipv4Addr::new(1, 0, 0, 1))]); assert_eq!(cfg.allow_domain, vec!["api.openai.com".to_string()]); assert_eq!(cfg.refresh_secs, 30); // serde default } diff --git a/crates/pasu-egress/tests/egress_e2e.rs b/crates/pasu-egress/tests/egress_e2e.rs index f6b98f2..fd33be0 100644 --- a/crates/pasu-egress/tests/egress_e2e.rs +++ b/crates/pasu-egress/tests/egress_e2e.rs @@ -28,6 +28,10 @@ const DOMAIN: &str = "one.one.one.one"; const OFF_DOMAIN_IP: &str = "8.8.8.8"; /// Unix socket for the control-plane admin API in the live-edit test. const ADMIN_SOCK: &str = "/tmp/pasu-e2e-admin.sock"; +/// Allowlisted IPv6 destination (Cloudflare one.one.one.one) — must connect. +const ALLOWED_V6: &str = "2606:4700:4700::1111"; +/// Non-allowlisted IPv6 destination (Cloudflare) — must be dropped. +const DENIED_V6: &str = "2606:4700:4700::1001"; fn e2e_enabled() -> bool { std::env::var_os("PASU_E2E_KERNEL").is_some() @@ -187,6 +191,41 @@ fn allowlist_by_domain_permits_resolved_denies_others() { ); } +/// IPv6 probe over a literal address (bash /dev/tcp doesn't take v6 literals). +fn v6_baseline(ip6: &str) -> bool { + tcp_check(&format!( + "command -v curl >/dev/null && curl -6 -sS --max-time 4 -o /dev/null http://[{ip6}]/" + )) +} +fn v6_child_connects_in_cgroup(ip6: &str) -> bool { + tcp_check(&format!( + "echo $$ > {CGROUP}/cgroup.procs && curl -6 -sS --max-time 6 -o /dev/null http://[{ip6}]/" + )) +} + +#[test] +fn ipv6_allowlist_permits_listed_denies_others() { + if should_skip() { + return; + } + // GitHub-hosted runners have no IPv6 egress; self-skip there. Exercises the + // v6 kernel path on a v6-capable host. (The v4 tests already prove the + // updated eBPF program — with the ALLOW6 map + v6 branch — loads/verifies.) + if !v6_baseline(ALLOWED_V6) || !v6_baseline(DENIED_V6) { + eprintln!("SKIP: no IPv6 baseline connectivity (runner has no v6?)."); + return; + } + let _guard = attach_guard(&[ALLOWED_V6], &[], None); + assert!( + v6_child_connects_in_cgroup(ALLOWED_V6), + "allowlisted v6 {ALLOWED_V6} must remain reachable" + ); + assert!( + !v6_child_connects_in_cgroup(DENIED_V6), + "non-allowlisted v6 {DENIED_V6} must be DROPPED under default-deny" + ); +} + #[test] fn admin_socket_allow_then_deny_toggles_egress_live() { if should_skip() { diff --git a/crates/pasu-rules/src/lib.rs b/crates/pasu-rules/src/lib.rs index 219bc6a..1fe74ad 100644 --- a/crates/pasu-rules/src/lib.rs +++ b/crates/pasu-rules/src/lib.rs @@ -10,7 +10,7 @@ //! the trait's callers (pasu-proxy, pasu-egress) decoupled from the rule format — //! swap this for OPA / a DSL later without touching them. Design: docs/rules.md -use std::net::Ipv4Addr; +use std::net::{Ipv4Addr, Ipv6Addr}; use pasu_core::{Event, EventKind, RuleEngine, Verdict}; use serde::Deserialize; @@ -68,7 +68,9 @@ pub struct Ruleset { pub struct EgressAllowlist { /// Host entries that parse as IPv4 — injected as static allow entries. pub ips: Vec, - /// Exact hostnames — resolved (and periodically re-resolved) to IPv4s. + /// Host entries that parse as IPv6 — injected as static allow entries. + pub ips6: Vec, + /// Exact hostnames — resolved (and periodically re-resolved) to IPs. pub domains: Vec, /// Allow rules the kernel layer cannot express, with the reason. These stay /// enforced at the hook layer only — surface them to the operator. @@ -134,6 +136,8 @@ impl Ruleset { }); } else if let Ok(ip) = host.parse::() { out.ips.push(ip); + } else if let Ok(ip6) = host.parse::() { + out.ips6.push(ip6); } else { out.domains.push(host.to_string()); } @@ -323,6 +327,21 @@ default: deny assert_eq!(out.skipped[0].rule, "allow-suffix"); } + #[test] + fn lowers_ipv6_literal_to_ips6() { + let rs = Ruleset::from_yaml( + "rules:\n - name: allow-v6\n match: { host: \"2606:4700:4700::1111\" }\n action: allow\n - name: allow-v4\n match: { host: \"1.1.1.1\" }\n action: allow\ndefault: deny\n", + ) + .unwrap(); + let out = rs.egress_allowlist().unwrap(); + assert_eq!(out.ips, vec![Ipv4Addr::new(1, 1, 1, 1)]); + assert_eq!( + out.ips6, + vec!["2606:4700:4700::1111".parse::().unwrap()] + ); + assert!(out.domains.is_empty()); + } + #[test] fn deny_ask_and_tool_only_rules_do_not_lower() { // TN pair: nothing here may widen the kernel allowlist. diff --git a/docs/deployment.md b/docs/deployment.md index 192d207..f974c67 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -109,5 +109,7 @@ cgroup layout. - **DNS / `--allow-domain`** re-resolves on an interval; because that lookup runs *after* attach, allow your DNS resolver's IP too, or prefer static `--allow` IPs where you can. -- Loopback and non-IPv4 traffic pass; IPv6 egress is not yet filtered. +- Both IPv4 and IPv6 egress are filtered (default-deny). Loopback (`127.0.0.0/8`, + `::1`) and v6 infrastructure prefixes (link-local `fe80::/10`, multicast + `ff00::/8`) always pass so basic networking keeps working. - This guards **egress**; it is not an ingress firewall.