From 184be98a4441c91ed24930c65871c086cdef59f4 Mon Sep 17 00:00:00 2001 From: KeyCode17 Date: Sat, 30 May 2026 09:19:25 +0700 Subject: [PATCH 1/3] feat(validation): add EgressPolicy SSRF guard (scheme allowlist + private/metadata/port/domain blocking) Co-Authored-By: Claude Sonnet 4.6 --- ras-validation/Cargo.toml | 1 + ras-validation/src/domain/egress.rs | 161 ++++++++++++++++++++++ ras-validation/src/domain/egress_tests.rs | 138 +++++++++++++++++++ ras-validation/src/domain/mod.rs | 1 + ras-validation/src/lib.rs | 1 + 5 files changed, 302 insertions(+) create mode 100644 ras-validation/src/domain/egress.rs create mode 100644 ras-validation/src/domain/egress_tests.rs diff --git a/ras-validation/Cargo.toml b/ras-validation/Cargo.toml index 81c2856..2f3a9dd 100644 --- a/ras-validation/Cargo.toml +++ b/ras-validation/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] ras-errors = { workspace = true } ras-types = { workspace = true } +url = { workspace = true } async-trait = { workspace = true } validator = { workspace = true } schemars = { workspace = true } diff --git a/ras-validation/src/domain/egress.rs b/ras-validation/src/domain/egress.rs new file mode 100644 index 0000000..0eb3e96 --- /dev/null +++ b/ras-validation/src/domain/egress.rs @@ -0,0 +1,161 @@ +//! Egress / SSRF policy engine — pure Rust, no browser dependency. + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use ras_types::DomainPattern; +use url::{Host, Url}; + +/// Errors produced by [`EgressPolicy::check`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EgressError { + /// The URL scheme is not in the allowed-schemes list. + ForbiddenScheme(String), + /// The host resolves to a private / loopback / link-local address. + PrivateAddress(String), + /// The host is explicitly denied (deny-list match or metadata endpoint). + DeniedHost(String), + /// The port is in the denied-ports list. + DeniedPort(u16), + /// An allow-list is configured and the host is not on it. + NotAllowlisted(String), + /// The URL has no host component. + NoHost, +} + +/// Always-on egress policy. [`EgressPolicy::check`] returns `Ok(())` only for permitted URLs. +pub struct EgressPolicy { + allowed_schemes: Vec, + block_private: bool, + denied_ports: Vec, + deny: Vec, + allow: Vec, +} + +impl Default for EgressPolicy { + fn default() -> Self { + Self { + allowed_schemes: vec!["http".into(), "https".into()], + block_private: true, + denied_ports: vec![], + deny: vec![], + allow: vec![], + } + } +} + +impl EgressPolicy { + /// Replace the allow-list with the supplied patterns. + #[must_use] + pub fn with_allow(mut self, patterns: Vec) -> Self { + self.allow = patterns; + self + } + + /// Replace the deny-list with the supplied patterns. + #[must_use] + pub fn with_deny(mut self, patterns: Vec) -> Self { + self.deny = patterns; + self + } + + /// Replace the denied-ports list. + #[must_use] + pub fn with_denied_ports(mut self, ports: Vec) -> Self { + self.denied_ports = ports; + self + } + + /// Toggle private-address blocking (`true` = allow private addresses through). + #[must_use] + pub fn allow_private(mut self, allow: bool) -> Self { + self.block_private = !allow; + self + } + + /// Validate `url` against this policy. + /// + /// Steps (in order): + /// 1. Scheme allow-list + /// 2. Host presence + /// 3. Private-address / metadata block + /// 4. Denied-ports block + /// 5. Deny-list patterns + /// 6. Allow-list patterns (if non-empty) + pub fn check(&self, url: &Url) -> Result<(), EgressError> { + if !self.allowed_schemes.iter().any(|s| s == url.scheme()) { + return Err(EgressError::ForbiddenScheme(url.scheme().into())); + } + + let host = match url.host() { + None => return Err(EgressError::NoHost), + Some(h) => h, + }; + + if self.block_private { + match &host { + Host::Ipv4(ip) => { + if is_forbidden_v4(*ip) { + return Err(EgressError::PrivateAddress(ip.to_string())); + } + } + Host::Ipv6(ip) => { + if is_forbidden_v6(*ip) { + return Err(EgressError::PrivateAddress(ip.to_string())); + } + } + Host::Domain(d) => { + let lower = d.to_lowercase(); + if lower == "localhost" || lower.ends_with(".localhost") { + return Err(EgressError::PrivateAddress(lower)); + } + if lower == "metadata.google.internal" { + return Err(EgressError::DeniedHost(lower)); + } + } + } + } + + if let Some(port) = url.port_or_known_default() + && self.denied_ports.contains(&port) + { + return Err(EgressError::DeniedPort(port)); + } + + let host_str = url.host_str().map(str::to_string).unwrap_or_default(); + + if self.deny.iter().any(|pat| pat.matches_url(url)) { + return Err(EgressError::DeniedHost(host_str.clone())); + } + + if !self.allow.is_empty() && !self.allow.iter().any(|pat| pat.matches_url(url)) { + return Err(EgressError::NotAllowlisted(host_str)); + } + + Ok(()) + } +} + +fn is_forbidden_v4(ip: Ipv4Addr) -> bool { + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() +} + +fn is_forbidden_v6(ip: Ipv6Addr) -> bool { + if ip.is_loopback() || ip.is_unspecified() { + return true; + } + if let Some(v4) = ip.to_ipv4_mapped() { + return is_forbidden_v4(v4); + } + let seg0 = ip.segments()[0]; + let unique_local = (seg0 & 0xfe00) == 0xfc00; + let link_local = (seg0 & 0xffc0) == 0xfe80; + unique_local || link_local +} + +#[cfg(test)] +#[path = "egress_tests.rs"] +mod tests; diff --git a/ras-validation/src/domain/egress_tests.rs b/ras-validation/src/domain/egress_tests.rs new file mode 100644 index 0000000..cc61c6d --- /dev/null +++ b/ras-validation/src/domain/egress_tests.rs @@ -0,0 +1,138 @@ +//! Unit tests for the egress policy engine. + +use super::{EgressError, EgressPolicy}; +use ras_types::DomainPattern; + +fn default() -> EgressPolicy { + EgressPolicy::default() +} + +fn url(s: &str) -> url::Url { + s.parse().expect("valid url") +} + +#[test] +fn public_http_https_allowed() { + let p = default(); + assert_eq!(p.check(&url("https://example.com/")), Ok(())); + assert_eq!(p.check(&url("http://example.com/")), Ok(())); +} + +#[test] +fn forbidden_schemes() { + let p = default(); + assert_eq!( + p.check(&url("file:///etc/passwd")), + Err(EgressError::ForbiddenScheme("file".into())) + ); + assert_eq!( + p.check(&url("chrome://settings")), + Err(EgressError::ForbiddenScheme("chrome".into())) + ); + assert_eq!( + p.check(&url("data:text/html,x")), + Err(EgressError::ForbiddenScheme("data".into())) + ); +} + +#[test] +fn private_ipv4() { + let p = default(); + assert_eq!( + p.check(&url("http://127.0.0.1/")), + Err(EgressError::PrivateAddress("127.0.0.1".into())) + ); + assert_eq!( + p.check(&url("http://10.0.0.5/")), + Err(EgressError::PrivateAddress("10.0.0.5".into())) + ); + assert_eq!( + p.check(&url("http://192.168.1.1/")), + Err(EgressError::PrivateAddress("192.168.1.1".into())) + ); + assert_eq!( + p.check(&url("http://172.16.0.1/")), + Err(EgressError::PrivateAddress("172.16.0.1".into())) + ); + assert_eq!( + p.check(&url("http://169.254.169.254/latest/meta-data/")), + Err(EgressError::PrivateAddress("169.254.169.254".into())) + ); +} + +#[test] +fn localhost_domain() { + let p = default(); + assert_eq!( + p.check(&url("http://localhost/")), + Err(EgressError::PrivateAddress("localhost".into())) + ); + assert_eq!( + p.check(&url("http://foo.localhost/")), + Err(EgressError::PrivateAddress("foo.localhost".into())) + ); +} + +#[test] +fn private_ipv6() { + let p = default(); + assert_eq!( + p.check(&url("http://[::1]/")), + Err(EgressError::PrivateAddress("::1".into())) + ); + assert_eq!( + p.check(&url("http://[fd00::1]/")), + Err(EgressError::PrivateAddress("fd00::1".into())) + ); + assert_eq!( + p.check(&url("http://[fe80::1]/")), + Err(EgressError::PrivateAddress("fe80::1".into())) + ); +} + +#[test] +fn metadata_google_internal() { + let p = default(); + assert_eq!( + p.check(&url("https://metadata.google.internal/")), + Err(EgressError::DeniedHost("metadata.google.internal".into())) + ); +} + +#[test] +fn denied_ports() { + let p = EgressPolicy::default().with_denied_ports(vec![9222]); + assert_eq!( + p.check(&url("http://example.com:9222/")), + Err(EgressError::DeniedPort(9222)) + ); + assert_eq!(p.check(&url("http://example.com:8080/")), Ok(())); +} + +#[test] +fn deny_list() { + let pat = DomainPattern::new("*.internal.corp").expect("pattern"); + let p = EgressPolicy::default().with_deny(vec![pat]); + assert_eq!( + p.check(&url("https://x.internal.corp/")), + Err(EgressError::DeniedHost("x.internal.corp".into())) + ); + assert_eq!(p.check(&url("https://example.com/")), Ok(())); +} + +#[test] +fn allow_list() { + let pat = DomainPattern::new("example.com").expect("pattern"); + let p = EgressPolicy::default().with_allow(vec![pat]); + assert_eq!(p.check(&url("https://example.com/")), Ok(())); + assert_eq!( + p.check(&url("https://other.com/")), + Err(EgressError::NotAllowlisted("other.com".into())) + ); +} + +#[test] +fn public_ip_allowed() { + let p = default(); + assert_eq!(p.check(&url("http://8.8.8.8/")), Ok(())); +} diff --git a/ras-validation/src/domain/mod.rs b/ras-validation/src/domain/mod.rs index 1d7a35f..4fa6b76 100644 --- a/ras-validation/src/domain/mod.rs +++ b/ras-validation/src/domain/mod.rs @@ -1 +1,2 @@ +pub mod egress; pub mod validated; diff --git a/ras-validation/src/lib.rs b/ras-validation/src/lib.rs index 1015d23..04f8fd4 100644 --- a/ras-validation/src/lib.rs +++ b/ras-validation/src/lib.rs @@ -2,4 +2,5 @@ pub mod application; pub mod domain; pub mod infrastructure; +pub use domain::egress::{EgressError, EgressPolicy}; pub use domain::validated::{Validated, ValidationFailure}; From 171b88e6074bc582208231114c04c88d0ce50d80 Mon Sep 17 00:00:00 2001 From: KeyCode17 Date: Sat, 30 May 2026 09:21:55 +0700 Subject: [PATCH 2/3] feat(tools): gate navigate through EgressPolicy before touching the browser Co-Authored-By: Claude Sonnet 4.6 --- ras-tools/Cargo.toml | 7 ++-- .../src/infrastructure/builtin/navigate.rs | 42 ++++++++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/ras-tools/Cargo.toml b/ras-tools/Cargo.toml index 8523d4d..496da36 100644 --- a/ras-tools/Cargo.toml +++ b/ras-tools/Cargo.toml @@ -12,9 +12,10 @@ authors.workspace = true workspace = true [dependencies] -ras-errors = { workspace = true } -ras-types = { workspace = true } -ras-cdp = { workspace = true } +ras-errors = { workspace = true } +ras-types = { workspace = true } +ras-validation = { workspace = true } +ras-cdp = { workspace = true } ras-dom = { workspace = true } ras-events = { workspace = true } async-trait = { workspace = true } diff --git a/ras-tools/src/infrastructure/builtin/navigate.rs b/ras-tools/src/infrastructure/builtin/navigate.rs index 32e1533..70e59ec 100644 --- a/ras-tools/src/infrastructure/builtin/navigate.rs +++ b/ras-tools/src/infrastructure/builtin/navigate.rs @@ -16,6 +16,15 @@ struct Params { url: String, } +fn parse_and_check(raw: &str) -> Result { + let url = + Url::parse(raw).map_err(|e| AppError::ValidationError(format!("invalid url: {e}")))?; + ras_validation::EgressPolicy::default() + .check(&url) + .map_err(|e| AppError::ValidationError(format!("blocked by egress policy: {e:?}")))?; + Ok(url) +} + #[async_trait] impl ToolHandler for NavigateAction { fn metadata(&self) -> ActionMetadata { @@ -40,10 +49,39 @@ impl ToolHandler for NavigateAction { ) -> Result { let p: Params = serde_json::from_value(params) .map_err(|e| AppError::ValidationError(format!("navigate params: {e}")))?; - let url = Url::parse(&p.url) - .map_err(|e| AppError::ValidationError(format!("invalid url: {e}")))?; + let url = parse_and_check(&p.url)?; let target = ctx.browser.focused_target().await?; ctx.browser.navigate(&target, &url).await?; Ok(ActionResult::ok(format!("navigated to {url}"))) } } + +#[cfg(test)] +mod tests { + use super::parse_and_check; + + #[test] + fn allows_public_https() { + assert!(parse_and_check("https://example.com/").is_ok()); + } + + #[test] + fn blocks_metadata_ip() { + assert!(parse_and_check("http://169.254.169.254/latest/meta-data/").is_err()); + } + + #[test] + fn blocks_localhost() { + assert!(parse_and_check("http://localhost:9222/json").is_err()); + } + + #[test] + fn blocks_file_scheme() { + assert!(parse_and_check("file:///etc/passwd").is_err()); + } + + #[test] + fn rejects_garbage_url() { + assert!(parse_and_check("not a url").is_err()); + } +} From 7570a3aba86df4309bda38e0f3d7d57c04fa6d45 Mon Sep 17 00:00:00 2001 From: KeyCode17 Date: Sat, 30 May 2026 09:22:36 +0700 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20bump=20to=203.3.0=20(Phase=202=20?= =?UTF-8?q?=E2=80=94=20egress/SSRF=20policy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 78 ++++++++++--------- Cargo.toml | 2 +- .../2026-05-30-multitenant-phase2-egress.md | 13 ++++ 3 files changed, 54 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-30-multitenant-phase2-egress.md diff --git a/Cargo.lock b/Cargo.lock index 16904ce..8fd8eac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,7 +1465,7 @@ dependencies = [ [[package]] name = "ras-agent" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "chrono", @@ -1497,7 +1497,7 @@ dependencies = [ [[package]] name = "ras-browser" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-cdp", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "ras-cdp" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "chromiumoxide", @@ -1534,7 +1534,7 @@ dependencies = [ [[package]] name = "ras-cli" -version = "3.2.0" +version = "3.3.0" dependencies = [ "anyhow", "clap", @@ -1564,7 +1564,7 @@ dependencies = [ [[package]] name = "ras-cloud" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1581,7 +1581,7 @@ dependencies = [ [[package]] name = "ras-config" -version = "3.2.0" +version = "3.3.0" dependencies = [ "dotenvy", "once_cell", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "ras-cosmium" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-cdp", @@ -1612,7 +1612,7 @@ dependencies = [ [[package]] name = "ras-daemon" -version = "3.2.0" +version = "3.3.0" dependencies = [ "anyhow", "dotenvy", @@ -1632,7 +1632,7 @@ dependencies = [ [[package]] name = "ras-dom" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "chromiumoxide", @@ -1652,7 +1652,7 @@ dependencies = [ [[package]] name = "ras-errors" -version = "3.2.0" +version = "3.3.0" dependencies = [ "serde", "thiserror", @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "ras-events" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-broadcast", "async-trait", @@ -1678,7 +1678,7 @@ dependencies = [ [[package]] name = "ras-filesystem" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1693,7 +1693,7 @@ dependencies = [ [[package]] name = "ras-judge" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "image", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "ras-llm" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "ras-llm-anthropic" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "chrono", @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "ras-llm-bedrock" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1762,7 +1762,7 @@ dependencies = [ [[package]] name = "ras-llm-cerebras" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1780,7 +1780,7 @@ dependencies = [ [[package]] name = "ras-llm-cloud" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "ras-llm-deepseek" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1815,7 +1815,7 @@ dependencies = [ [[package]] name = "ras-llm-google" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "ras-llm-groq" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "ras-llm-langchain" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1867,7 +1867,7 @@ dependencies = [ [[package]] name = "ras-llm-mistral" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1885,7 +1885,7 @@ dependencies = [ [[package]] name = "ras-llm-oci" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1902,7 +1902,7 @@ dependencies = [ [[package]] name = "ras-llm-ollama" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1919,7 +1919,7 @@ dependencies = [ [[package]] name = "ras-llm-openai" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1936,7 +1936,7 @@ dependencies = [ [[package]] name = "ras-llm-openrouter" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1954,7 +1954,7 @@ dependencies = [ [[package]] name = "ras-llm-vercel" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1972,7 +1972,7 @@ dependencies = [ [[package]] name = "ras-mcp" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "ras-recording" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "image", @@ -2004,7 +2004,7 @@ dependencies = [ [[package]] name = "ras-sandbox" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -2017,7 +2017,7 @@ dependencies = [ [[package]] name = "ras-skills" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "ras-telemetry" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -2048,7 +2048,7 @@ dependencies = [ [[package]] name = "ras-tokens" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "chrono", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "ras-tools" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "indexmap", @@ -2075,6 +2075,7 @@ dependencies = [ "ras-errors", "ras-events", "ras-types", + "ras-validation", "regex", "schemars", "serde", @@ -2087,7 +2088,7 @@ dependencies = [ [[package]] name = "ras-types" -version = "3.2.0" +version = "3.3.0" dependencies = [ "chrono", "indexmap", @@ -2103,7 +2104,7 @@ dependencies = [ [[package]] name = "ras-validation" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-errors", @@ -2112,12 +2113,13 @@ dependencies = [ "serde", "serde_json", "thiserror", + "url", "validator", ] [[package]] name = "ras-watchdogs" -version = "3.2.0" +version = "3.3.0" dependencies = [ "async-trait", "ras-browser", diff --git a/Cargo.toml b/Cargo.toml index eab840b..d9705c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ exclude = ["examples", "tests", "docs", "scripts"] default-members = ["ras-cli", "ras-daemon"] [workspace.package] -version = "3.2.0" +version = "3.3.0" edition = "2024" rust-version = "1.95.0" license = "MIT" diff --git a/docs/superpowers/plans/2026-05-30-multitenant-phase2-egress.md b/docs/superpowers/plans/2026-05-30-multitenant-phase2-egress.md new file mode 100644 index 0000000..e8ea116 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-multitenant-phase2-egress.md @@ -0,0 +1,13 @@ +# Multi-Tenant Phase 2 — Egress / SSRF Policy (AR-5) + +**Goal:** Stop a tenant's agent from reaching forbidden destinations (cloud metadata, loopback incl. the CDP port, private ranges, `file:`/`chrome:`/`data:`), via a pure `EgressPolicy` engine plus a navigation gate. + +**Tech:** Rust, `url`, `std::net`, `ras_types::DomainPattern`. + +**Repo rules:** ≤200 LOC/file, no `//` comments, no `.unwrap()` in non-test code, conventional commits. + +## Tasks +1. **EgressPolicy engine** — `ras-validation/src/domain/egress.rs`: pure `check(&Url) -> Result<(), EgressError>`. Scheme allowlist (default http/https), block loopback/private/link-local/metadata IPs + `localhost`, denied ports, consumer allow/deny via `DomainPattern`. Comprehensive offline unit tests. +2. **Navigate gate** — `ras-tools/src/infrastructure/builtin/navigate.rs`: run `EgressPolicy::default().check(&url)` before navigating; reject with `ValidationError`. + +**Scope note:** The CDP-layer `Fetch.requestPaused` enforcement (covers 3xx redirects + JS `window.location`, per AR-5) is **Phase 2b**, tracked separately — this phase ships the policy engine + the direct-navigation gate (closes the primary SSRF vector: an agent steered straight to metadata/localhost/file). DNS-rebinding (host resolves to a private IP at fetch time) is also deferred to 2b with the request-layer hook.