Network Guards
Two guards govern outbound network access: EgressAllowlistGuard enforces a domain allowlist over ToolAction::NetworkEgress, and InternalNetworkGuard blocks SSRF targets (private ranges, cloud metadata endpoints, encoded IP literals). Both run on the same action variant; they apply in series so a request must clear both to reach the network.
EgressAllowlistGuard
Source: crates/chio-guards/src/egress_allowlist.rs. Guard name: egress-allowlist. Domain matching uses glob::Pattern against the lowercased host string.
Struct
pub struct EgressAllowlistGuard {
allow_patterns: Vec<glob::Pattern>,
block_patterns: Vec<glob::Pattern>,
}
impl EgressAllowlistGuard {
pub fn new() -> Self;
pub fn with_lists(
allow: Vec<String>,
block: Vec<String>,
) -> Result<Self, EgressAllowlistConfigError>;
pub fn is_allowed(&self, domain: &str) -> bool;
}Default allowlist
Verified from default_allow_patterns():
// Common AI/ML APIs
"*.openai.com"
"*.anthropic.com"
"api.github.com"
// Package registries
"*.npmjs.org"
"registry.npmjs.org"
"pypi.org"
"files.pythonhosted.org"
"crates.io"
"static.crates.io"That is the entire default. Anything else is denied. The default blocklist is empty.
Configuration
| Knob | Type | Default | Purpose |
|---|---|---|---|
allow_patterns | Vec<String> | 9 globs (above) | Allowed hosts. Domain must match at least one. |
block_patterns | Vec<String> | empty | Hard blocks that override the allowlist. Checked first. |
Algorithm
- Lowercase the input host.
- For each block pattern, deny on first match. Block list takes precedence over allow list.
- For each allow pattern, allow on first match.
- Default fall-through: deny.
Bare-domain wildcard quirk
*.example.com matches api.example.com but not the bare example.com. Add the bare domain explicitly when you need it.Failure modes
- Invalid allow glob ::
EgressAllowlistConfigError::InvalidAllowPattern. - Invalid block glob ::
EgressAllowlistConfigError::InvalidBlockPattern. - Non-network actions return
Verdict::Allow. EgressAllowlistGuard::new()panics if the default config fails to compile (treated as a source-level invariant).
Example
guards:
egress_allowlist:
allow_patterns:
- "*.openai.com"
- "api.anthropic.com"
- "internal.corp.example"
block_patterns:
- "*.public-mirror.example"InternalNetworkGuard
SSRF defense. Source: crates/chio-guards/src/internal_network.rs. Guard name: internal-network. Runs in addition to (not instead of) EgressAllowlistGuard.
Struct
pub struct InternalNetworkGuard {
extra_blocked_hosts: Vec<String>,
dns_rebinding_detection: bool,
}
impl InternalNetworkGuard {
pub fn new() -> Self;
pub fn with_config(
extra_blocked_hosts: Vec<String>,
dns_rebinding_detection: bool,
) -> Self;
pub fn check_host(&self, host: &str) -> Option<String>;
}Configuration
| Knob | Type | Default | Purpose |
|---|---|---|---|
extra_blocked_hosts | Vec<String> | empty | Additional exact-match hostnames to block (case-insensitive). |
dns_rebinding_detection | bool | true | When on, hostnames containing IP-octet substrings flag. |
Blocked address classes
From is_private_ip and is_cloud_metadata_host:
| Class | Range / value | Source |
|---|---|---|
| IPv4 loopback | 127.0.0.0/8 | RFC 1122 |
| IPv4 private (Class A) | 10.0.0.0/8 | RFC 1918 |
| IPv4 private (Class B) | 172.16.0.0/12 | RFC 1918 |
| IPv4 private (Class C) | 192.168.0.0/16 | RFC 1918 |
| IPv4 link-local | 169.254.0.0/16 | RFC 3927 |
| IPv4 broadcast | 255.255.255.255 | RFC 919 |
| IPv4 zero network | 0.0.0.0/8 | RFC 791 |
| IPv6 loopback | ::1 | RFC 4291 |
| IPv6 link-local | fe80::/10 | RFC 4291 |
| IPv6 unique-local | fc00::/7 | RFC 4193 |
| IPv6 unspecified | :: | RFC 4291 |
| IPv4-mapped IPv6 | ::ffff:<ipv4> when ipv4 is private | RFC 4291 |
| Cloud metadata IP | 169.254.169.254 | AWS / GCP / Azure |
| GCP metadata host | metadata.google.internal | GCP |
| Azure metadata host | metadata.azure.com | Azure |
| EC2 instance-data | instance-data | AWS |
| Internal TLD | any host ending in .internal | conventional |
| Kubernetes API | kubernetes.default.svc, kubernetes.default | Kubernetes |
The .internal suffix is a coarse block
.internal is blocked unconditionally. If your tenancy uses .internal for legitimate public-facing services, you must override this guard. The check is literal substring on the lowercased host.Encoded-IP detection
From looks_like_encoded_ip. The guard tries to catch hostnames that obfuscate a private-IP literal:
| Encoding | Example | Heuristic |
|---|---|---|
| Hexadecimal | 0x7f000001 | Starts with 0x, all hex digits. |
| Decimal | 2130706433 (= 127.0.0.1) | 7 to 10 ASCII digits. |
| Octal-dotted | 0177.0.0.1 | Starts with 0, contains a dot, and at least one component starts with 0 and is > 1 char. |
DNS-rebinding heuristics
From is_dns_rebinding_suspect. When a hostname is not itself a parseable IP but contains a substring that looks like a private IP, the guard flags it. The matched substrings:
let suspicious_patterns = [
"127-0-0-1",
"127.0.0.1",
"10-0-",
"10.0.",
"192-168-",
"192.168.",
"172-16-",
"172.16.",
"169-254-",
"169.254.",
"0x7f", // hex-encoded 127
"0177.", // octal 127
];So evil.127-0-0-1.attacker.com and evil.192-168-1.attacker.com flag. Disable this layer with with_config(_, false) if you accept the false-positive risk.
Algorithm
- Lowercase the host. Match cloud-metadata names first (literal table plus the
.internalsuffix). - Compare against
extra_blocked_hosts(case-insensitive equality). - If
dns_rebinding_detectionis on and the host is not itself a parseable IP, run the suspicious- substring scan. - Try to parse the host as an
IpAddr. If it parses, runis_private_ip. If the host does not parse, runlooks_like_encoded_ip. - The first match wins. Otherwise allow.
Failure modes
- Non-network actions return
Verdict::Allow. - The guard does no DNS lookups. It cannot block an attacker-controlled hostname that resolves to a private IP at TCP-connect time. Pair it with a network-layer egress proxy that re-validates after DNS, or with a host-level firewall that enforces the same private-range policy.
Stacking the two guards
Both guards inspect ToolAction::NetworkEgress(host, _). Register them in the order below so an SSRF target is denied even when the egress allowlist is permissive:
use chio_guards::{EgressAllowlistGuard, InternalNetworkGuard};
let mut pipeline = chio_guards::GuardPipeline::new();
pipeline.add(Box::new(InternalNetworkGuard::new())); // SSRF first
pipeline.add(Box::new(EgressAllowlistGuard::new())); // domain allowlistDefense in depth
169.254.169.254. Running InternalNetworkGuard before the allowlist closes that gap for IP literals; pairing with a proxy that revalidates after DNS resolution closes the resolved-host gap.Next Steps
- Shell & Code Guards :: command-line and interpreter checks.
- Filesystem Guards :: forbidden paths, allowlist, secret leak, patch integrity.
- External Guards :: cloud-content-safety adapters with their own SSRF posture.