fix(security): SSRF guard on workflow http step — Bug #56 (HIGH) - #81
Conversation
`crates/workflows/src/lib.rs::run_http_step` accepted any URL from a workflow YAML and sent an HTTP request to it with the workflow runtime's network access. Workflows come from three sources: 1. The user's `~/.prism/workflows/` (user-trusted). 2. Marketplace installs (third-party). 3. LLM-generated workflows during agent execution. #2 and #3 are NOT trusted, but the http step ran identical code. Real attack examples a malicious workflow could trigger: - GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ → exfil AWS instance IAM credentials when running on EC2 - GET http://localhost:7327/api/users → list local PRISM users via the local node's own API - GET http://10.0.0.1:8200/v1/secret/data/... → read from internal Vault if reachable - file:///etc/passwd → read server-side files ## Fix Added `ssrf_block_reason()` that runs before every http step's request. Blocks: - Non-http(s) schemes (file/gopher/dict/data/ftp/...) - Unparseable URLs - Literal hostnames: localhost, ip6-localhost, metadata, metadata.google.internal, ... - Literal IPv4 in loopback / RFC1918 / link-local / broadcast / unspecified ranges - 169.254.169.254 (cloud instance metadata) called out specifically - Literal IPv6 in loopback / unspecified (incl. [::1] form) Returns clear "rejected by SSRF guard: <reason>" error so the workflow author can see why their YAML was refused. What this DOESN'T defend against (documented in the function comment): - DNS rebinding (host resolves to public IP at parse time, then to internal IP at request time). Defending against that needs custom DNS resolution + IP re-check at connect time. - Hosts the attacker controls and points at internal infra. For sensitive networks the right long-term fix is a hostname allowlist in the workflow runtime config; this PR is the obvious- attacks gate that blocks the common payloads. 6 new unit tests cover public-URL allow, localhost block, RFC1918 block, link-local + metadata, dangerous schemes, unparseable. Severity: HIGH — workflows execute with the runtime's network access and any creds it holds (incl. cloud IAM if running on EC2). SSRF is the textbook path from "workflow runs on a server" to "attacker has cloud creds". Same threat-model class as #75/#76/#54. 29 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR adds Server-Side Request Forgery (SSRF) protection to HTTP workflow steps. A new ChangesSSRF Protection for HTTP Workflows
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
crates/workflows/src/lib.rs (1)
1917-1961: ⚡ Quick winSolid baseline test coverage.
The six new tests cover the main classes the doc-comment promises (public allow, localhost, RFC1918, link-local + metadata, dangerous schemes, unparseable). Consider extending with the IPv6-mapped and trailing-dot cases noted above, plus a couple of edge cases that are easy wins given the rest of the suite already exists:
http://0/(WHATWG normalizes to0.0.0.0)http://2130706433/(decimal-encoded 127.0.0.1, normalized by url crate)https://[::ffff:127.0.0.1]/These guard against future regressions in either url-crate normalization or the guard itself.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/workflows/src/lib.rs` around lines 1917 - 1961, Add tests to cover IPv6-mapped and numeric/short host normalizations by extending the ssrf suite: add assertions that ssrf_block_reason("http://0/"), ssrf_block_reason("http://2130706433/"), and ssrf_block_reason("https://[::ffff:127.0.0.1]/") are Some (i.e. blocked). Put them alongside the other tests (e.g. in a new test function like ssrf_blocks_additional_edge_cases) referencing the ssrf_block_reason function so the suite guards against URL normalization/regression by the url crate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/workflows/src/lib.rs`:
- Around line 1416-1440: The IPv6 branch currently only checks
is_loopback/is_unspecified and skips all IPv4 checks and IPv6-specific ranges;
update the IPv6 handling in ssrf_block_reason to (1) detect IPv4-mapped IPv6
addresses by calling to_ipv4() on the parsed std::net::Ipv6Addr and, if
Some(ipv4), run the existing IPv4 checks (is_loopback, is_private,
is_link_local, is_unspecified, broadcast, and the 169.254.169.254 metadata
check) against that canonical IPv4 before returning; (2) for native IPv6
addresses, add checks for link-local (check segs[0] & 0xffc0 == 0xfe80) and
unique-local (check segs[0] & 0xfe00 == 0xfc00) and return appropriate block
reasons; finally, add the suggested tests
(ssrf_blocks_ipv6_mapped_ipv4_private_and_metadata and
ssrf_blocks_ipv6_link_local_and_unique_local) to exercise these cases.
---
Nitpick comments:
In `@crates/workflows/src/lib.rs`:
- Around line 1917-1961: Add tests to cover IPv6-mapped and numeric/short host
normalizations by extending the ssrf suite: add assertions that
ssrf_block_reason("http://0/"), ssrf_block_reason("http://2130706433/"), and
ssrf_block_reason("https://[::ffff:127.0.0.1]/") are Some (i.e. blocked). Put
them alongside the other tests (e.g. in a new test function like
ssrf_blocks_additional_edge_cases) referencing the ssrf_block_reason function so
the suite guards against URL normalization/regression by the url crate.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c7531a48-3f86-4181-a9b2-9964ed0d43c7
📒 Files selected for processing (1)
crates/workflows/src/lib.rs
| if let Ok(ipv4) = host.parse::<std::net::Ipv4Addr>() { | ||
| if ipv4.is_loopback() | ||
| || ipv4.is_private() | ||
| || ipv4.is_link_local() | ||
| || ipv4.is_unspecified() | ||
| || ipv4.is_broadcast() | ||
| { | ||
| return Some("IPv4 address is in a private / loopback / link-local range"); | ||
| } | ||
| // 169.254.169.254 is link-local but call it out specifically. | ||
| if ipv4.octets() == [169, 254, 169, 254] { | ||
| return Some("address is the cloud instance metadata service"); | ||
| } | ||
| } | ||
| // url crate strips the [] from IPv6 hosts, but the host_str() form | ||
| // returns it WITH the brackets. Strip them before parsing. | ||
| let ipv6_candidate = host | ||
| .strip_prefix('[') | ||
| .and_then(|s| s.strip_suffix(']')) | ||
| .unwrap_or(host.as_str()); | ||
| if let Ok(ipv6) = ipv6_candidate.parse::<std::net::Ipv6Addr>() | ||
| && (ipv6.is_loopback() || ipv6.is_unspecified()) | ||
| { | ||
| return Some("IPv6 address is loopback / unspecified"); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let me check the actual code at the specified lines
cat -n crates/workflows/src/lib.rs | sed -n '1416,1440p'Repository: Darth-Hidious/PRISM
Length of output: 1258
🏁 Script executed:
# Also check a broader range to see the full function
cat -n crates/workflows/src/lib.rs | sed -n '1383,1450p'Repository: Darth-Hidious/PRISM
Length of output: 2834
🏁 Script executed:
# Check if run_http_step properly calls ssrf_block_reason
cat -n crates/workflows/src/lib.rs | sed -n '660,672p'Repository: Darth-Hidious/PRISM
Length of output: 780
🏁 Script executed:
# Look for existing tests related to ssrf_block_reason
rg -n "ssrf_block_reason|ssrf_blocks" crates/workflows/src/lib.rs | head -20Repository: Darth-Hidious/PRISM
Length of output: 1500
🏁 Script executed:
cd crates/workflows && cat > /tmp/test_ssrf_bypass.rs << 'EOF'
// Test to verify IPv6 SSRF bypasses
fn main() {
// Test 1: IPv4-mapped IPv6 loopback
let ipv6_mapped_lo = "::ffff:127.0.0.1".parse::<std::net::Ipv6Addr>().unwrap();
println!("::ffff:127.0.0.1 is_loopback: {}", ipv6_mapped_lo.is_loopback());
if let Some(v4) = ipv6_mapped_lo.to_ipv4() {
println!("::ffff:127.0.0.1 to_ipv4: {} is_loopback: {}", v4, v4.is_loopback());
}
// Test 2: IPv4-mapped IPv6 metadata
let ipv6_mapped_meta = "::ffff:169.254.169.254".parse::<std::net::Ipv6Addr>().unwrap();
println!("::ffff:169.254.169.254 is_loopback: {}", ipv6_mapped_meta.is_loopback());
if let Some(v4) = ipv6_mapped_meta.to_ipv4() {
println!("::ffff:169.254.169.254 to_ipv4: {} is_link_local: {}", v4, v4.is_link_local());
}
// Test 3: Link-local address
let ipv6_link_local = "fe80::1".parse::<std::net::Ipv6Addr>().unwrap();
println!("fe80::1 is_loopback: {}", ipv6_link_local.is_loopback());
println!("fe80::1 is_unspecified: {}", ipv6_link_local.is_unspecified());
let segs = ipv6_link_local.segments();
println!("fe80::1 first segment: 0x{:x}, 0xffc0 check: {}", segs[0], (segs[0] & 0xffc0) == 0xfe80);
// Test 4: Unique-local address
let ipv6_unique_local = "fc00::1".parse::<std::net::Ipv6Addr>().unwrap();
println!("fc00::1 is_loopback: {}", ipv6_unique_local.is_loopback());
println!("fc00::1 is_unspecified: {}", ipv6_unique_local.is_unspecified());
let segs = ipv6_unique_local.segments();
println!("fc00::1 first segment: 0x{:x}, 0xfe00 check: {}", segs[0], (segs[0] & 0xfe00) == 0xfc00);
}
EOF
rustc /tmp/test_ssrf_bypass.rs -o /tmp/test_ssrf && /tmp/test_ssrfRepository: Darth-Hidious/PRISM
Length of output: 110
🏁 Script executed:
# Now let's check what the actual ssrf_block_reason returns for these URLs
cd crates/workflows && cat > /tmp/test_ssrf_urls.rs << 'EOF'
use std::path::PathBuf;
// Include the ssrf_block_reason function from lib.rs
// We'll need to create a minimal test that imports from the library
fn main() {
// Since we can't easily import private functions, let's check if there's a test module
println!("Checking test file for ssrf tests...");
}
EOFRepository: Darth-Hidious/PRISM
Length of output: 45
🏁 Script executed:
cd crates/workflows && grep -A 20 "fn ssrf_blocks_localhost" src/lib.rsRepository: Darth-Hidious/PRISM
Length of output: 1081
🏁 Script executed:
# Let's look at the test module more closely to understand the test structure
cd crates/workflows && sed -n '1915,1950p' src/lib.rsRepository: Darth-Hidious/PRISM
Length of output: 1599
🏁 Script executed:
# Now let's write a comprehensive test that will check the bypass cases
# First check the Cargo.toml to understand dependencies
cd crates/workflows && head -20 Cargo.tomlRepository: Darth-Hidious/PRISM
Length of output: 569
🌐 Web query:
Rust Ipv6Addr is_loopback() IPv4-mapped ::ffff behavior
💡 Result:
In Rust std::net::Ipv6Addr::is_loopback, the IPv4-mapped loopback range (::ffff:127.0.0.0/104) is treated as loopback, while other IPv4-in-IPv6 encodings (e.g., IPv4-compatible::127.0.0.0/104) are not. [1][2] So, for the specific address::ffff:127.0.0.1 (which lies in::ffff:127.0.0.0/104), Ipv6Addr::is_loopback returns true. [1] How this fits with “canonical IPv4” conversion: - The Ipv6Addr docs note that an address like::ffff:127.0.0.1 is not itself an IPv6 loopback address in the sense of “only::1 is the IPv6 loopback address”; to treat these “IPv4-in-IPv6” addresses as loopback consistently, convert to canonical IPv4 and then check. [2] - However, newer Rust behavior explicitly updates Ipv6Addr::is_loopback to include IPv4-mapped loopback addresses in addition to::1. [1] Example (expected behavior): - Ipv6Addr::new(0,0,0,0,0,0xffff,0x7f00,0x1).is_loopback == true (because it matches::ffff:127.0.0.0/104). [1][3] - An address that is IPv4-compatible but not IPv4-mapped (e.g.,::127.0.0.1 /::127.0.0.0/104) is not considered loopback by is_loopback. [1][2] References: [1] rust-lang/rust#85655 [3] rust-lang/rust#69772 [2] https://doc.rust-lang.org/std/net/struct.Ipv6Addr.html
Citations:
- 1: Change
Ipv6Addr::is_loopbackto include IPv4-mapped loopback addresses rust-lang/rust#85655 - 2: https://doc.rust-lang.org/std/net/struct.Ipv6Addr.html
- 3: std::net::IpAddr: is_loopback failing on ipv4-in-ipv6 addresses rust-lang/rust#69772
🌐 Web query:
Rust std::net::Ipv6Addr::to_ipv4() documentation
💡 Result:
In Rust, std::net::Ipv6Addr::to_ipv4 is documented as a const method with signature pub const fn to_ipv4(&self) -> Option<Ipv4Addr> and it “Converts this address to an IPv4 address if it is either an IPv4-compatible address … or an IPv4-mapped address …, otherwise returns None.” [1] Key documented behavior: 1) It returns Some(Ipv4Addr) only for IPv4-compatible IPv6 addresses and IPv4-mapped IPv6 addresses; otherwise it returns None. [1] 2) It also returns an IPv4 address for the IPv6 loopback address::1, specifically 0.0.0.1. [1] 3) The documentation notes the mapping rules: ::a.b.c.d and ::ffff:a.b.c.d become a.b.c.d, and ::1 becomes 0.0.0.1; other addresses return None. [1] Example code shown in the docs: - Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0).to_ipv4() returns None. [1] - Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc00a, 0x2ff).to_ipv4() returns Some(192.10.2.255). [1] - Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1).to_ipv4() returns Some(0.0.0.1). [1] Note: The docs specifically distinguish this method from to_ipv4_mapped, which avoids converting::1 (per the docs’ note). [1]
Citations:
🏁 Script executed:
# Let's check if there are any comments or documentation in the code about IPv6 handling
rg -B 5 -A 15 "IPv6" crates/workflows/src/lib.rs | head -50Repository: Darth-Hidious/PRISM
Length of output: 1054
🏁 Script executed:
# Check the complete ssrf_block_reason test section to see all tests
sed -n '1917,1970p' crates/workflows/src/lib.rsRepository: Darth-Hidious/PRISM
Length of output: 1984
🏁 Script executed:
# Check the Rust version in the project
cat crates/workflows/Cargo.toml | grep -A 5 -B 5 "rust"Repository: Darth-Hidious/PRISM
Length of output: 45
🏁 Script executed:
# Check workspace Cargo.toml for edition
cat Cargo.toml | head -20Repository: Darth-Hidious/PRISM
Length of output: 549
🏁 Script executed:
# Let's also verify which methods are available on Ipv6Addr in the codebase
# by checking if there are any other IPv6 checks elsewhere
rg "is_link_local|is_private|is_broadcast|is_unspecified" crates/workflows/src/lib.rsRepository: Darth-Hidious/PRISM
Length of output: 261
🏁 Script executed:
# Let's verify what version of Rust standardized the IPv4-mapped loopback check
# by checking the GitHub PR `#85655`
web_search "rust-lang/rust PR 85655 IPv4-mapped loopback Ipv6Addr"Repository: Darth-Hidious/PRISM
Length of output: 114
🌐 Web query:
Rust 1.92 Ipv6Addr::is_loopback() IPv4-mapped ::ffff:127.0.0.0 behavior
💡 Result:
In Rust 1.92, Ipv6Addr::is_loopback returns true only for the IPv6 loopback address::1. It does not treat IPv4-mapped IPv6 loopback addresses such as::ffff:127.0.0.1 (or::ffff:127.0.0.0/104) as loopback; those are not “IPv6 loopback” addresses by this predicate. [1] However, the Rust docs explain that IPv4-in-IPv6 addresses (both IPv4-compatible and IPv4-mapped) have no special meaning as IPv6 addresses, and must first be converted to their canonical IPv4 form before applying IPv4 classification like is_loopback. [1][2] What this means for::ffff:127.0.0.x specifically 1) IPv6: - let a: Ipv6Addr = "::ffff:127.0.0.1".parse.unwrap; - a.is_loopback is false. (Rust’s is_loopback is defined as::1 only.) [1] 2) Convert to IPv4 and then check: - a.to_canonical yields an IpAddr::V4(127.0.0.1) for IPv4-mapped addresses, and is_loopback on that canonical value returns true. The docs give the exact pattern: use to_canonical.is_loopback to make IPv4-in-IPv6 behave like IPv4 loopback. [2][3] Context / background - A rust-lang issue reports that is_loopback didn’t recognize::ffff:127.0.0.1 as loopback and notes the need to convert to IPv4 first; a maintainer discussion/PR history reflects this as an intentional/documented behavior rather than a simple bug fix. [3][4] Recommended pattern in Rust - If you may receive IPv4-mapped IPv6 addresses, don’t rely on Ipv6Addr::is_loopback alone. - Instead, convert to canonical form first (e.g., ip.to_canonical or addr.to_canonical) and then call is_loopback on the result. [2][3]
Citations:
- 1: https://doc.rust-lang.org/1.92.0/std/net/struct.Ipv6Addr.html
- 2: https://doc.rust-lang.org/1.91.1/std/net/enum.IpAddr.html
- 3: std::net::IpAddr: is_loopback failing on ipv4-in-ipv6 addresses rust-lang/rust#69772
- 4: https://users.rust-lang.org/t/is-loopback-failing-on-ipv4-in-ipv6-bug-or-desired-behavior/39087
🏁 Script executed:
# Let's create a simple test to demonstrate the actual vulnerabilities
# by checking what the code WOULD allow through
cat > /tmp/analyze_gaps.md << 'EOF'
# IPv6 SSRF Guard Analysis
## Current implementation (lines 1436-1440):
Only checks: ipv6.is_loopback() || ipv6.is_unspecified()
## Vulnerabilities:
1. Missing: is_private() on IPv6
2. Missing: is_link_local() on IPv6
3. Missing: IPv4-mapped address conversion
4. Missing: IPv6 link-local range (fe80::/10)
5. Missing: IPv6 unique-local range (fc00::/7)
## What bypasses:
- http://[::ffff:10.0.0.1]/ → RFC1918 private
- http://[::ffff:192.168.1.1]/ → RFC1918 private
- http://[::ffff:169.254.169.254]/ → AWS metadata (if not caught by special check)
- http://[fe80::1]/ → Link-local
- http://[fc00::1]/ → Unique-local
- http://[fd12:3456:789a::1]/ → Unique-local
## What IS caught:
- http://[::1]/ → is_loopback()
- http://[::ffff:127.0.0.1]/ → is_loopback() (if ::ffff:127.0.0.0/104 included)
EOF
cat /tmp/analyze_gaps.mdRepository: Darth-Hidious/PRISM
Length of output: 863
🌐 Web query:
Rust std::net::Ipv6Addr::is_private() documentation
💡 Result:
Rust’s std::net::Ipv6Addr does not have an is_private method. The official Rust documentation for Ipv6Addr lists classification helpers such as is_unspecified, is_loopback, is_documentation, is_benchmarking, is_multicast, is_unicast_global, and others, but there is no is_private entry on Ipv6Addr’s API page [1]. By contrast, the is_private API exists for Ipv4Addr (and is implemented in Rust’s net/ip_addr.rs), where it is defined in terms of RFC 1918 ranges for IPv4 [2].
Citations:
- 1: https://doc.rust-lang.org/std/net/struct.Ipv6Addr.html
- 2: https://web.mit.edu/rust-lang_v1.25/arch/amd64_ubuntu1404/share/doc/rust/html/std/net/struct.Ipv6Addr.html
SSRF bypass via IPv6 addresses: IPv4-mapped private ranges, metadata service, link-local, and unique-local not blocked.
Rust 1.92's Ipv6Addr::is_loopback() matches only ::1—not IPv4-mapped loopback addresses. More critically, the IPv6 checks at lines 1436-1440 skip the entire suite of IPv4 checks (is_private(), is_link_local(), is_unspecified(), is_broadcast()), leaving these bypasses:
http://[::ffff:10.0.0.1]/,http://[::ffff:192.168.1.1]/→ RFC1918 privatehttp://[::ffff:169.254.169.254]/→ AWS/GCP metadata servicehttp://[fe80::1]/and anyfe80::/10→ IPv6 link-localhttp://[fc00::1]/,http://[fd12:3456:789a::1]/→ IPv6 unique-local
Convert any IPv4-in-IPv6 address to its canonical IPv4 form via to_ipv4() and re-run the v4 checks; add manual segment checks for link-local (segs[0] & 0xffc0 == 0xfe80) and unique-local (segs[0] & 0xfe00 == 0xfc00).
Proposed fix
+ // Helper to avoid duplicating v4 checks for raw and IPv6-embedded forms.
+ fn ipv4_block_reason(ipv4: std::net::Ipv4Addr) -> Option<&'static str> {
+ if ipv4.is_loopback()
+ || ipv4.is_private()
+ || ipv4.is_link_local()
+ || ipv4.is_unspecified()
+ || ipv4.is_broadcast()
+ {
+ return Some("IPv4 address is in a private / loopback / link-local range");
+ }
+ if ipv4.octets() == [169, 254, 169, 254] {
+ return Some("address is the cloud instance metadata service");
+ }
+ None
+ }
+
- if let Ok(ipv4) = host.parse::<std::net::Ipv4Addr>() {
- if ipv4.is_loopback()
- || ipv4.is_private()
- || ipv4.is_link_local()
- || ipv4.is_unspecified()
- || ipv4.is_broadcast()
- {
- return Some("IPv4 address is in a private / loopback / link-local range");
- }
- // 169.254.169.254 is link-local but call it out specifically.
- if ipv4.octets() == [169, 254, 169, 254] {
- return Some("address is the cloud instance metadata service");
- }
+ if let Ok(ipv4) = host.parse::<std::net::Ipv4Addr>()
+ && let Some(r) = ipv4_block_reason(ipv4)
+ {
+ return Some(r);
}
// url crate strips the [] from IPv6 hosts, but the host_str() form
// returns it WITH the brackets. Strip them before parsing.
let ipv6_candidate = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host.as_str());
- if let Ok(ipv6) = ipv6_candidate.parse::<std::net::Ipv6Addr>()
- && (ipv6.is_loopback() || ipv6.is_unspecified())
- {
- return Some("IPv6 address is loopback / unspecified");
+ if let Ok(ipv6) = ipv6_candidate.parse::<std::net::Ipv6Addr>() {
+ if ipv6.is_loopback() || ipv6.is_unspecified() {
+ return Some("IPv6 address is loopback / unspecified");
+ }
+ // IPv4-in-IPv6: Rust does not treat ::ffff:a.b.c.d as loopback,
+ // so convert and re-check against v4 ranges.
+ if let Some(v4) = ipv6.to_ipv4()
+ && let Some(r) = ipv4_block_reason(v4)
+ {
+ return Some(r);
+ }
+ let segs = ipv6.segments();
+ // Unique local addresses: fc00::/7
+ if segs[0] & 0xfe00 == 0xfc00 {
+ return Some("IPv6 address is in the unique-local range");
+ }
+ // Unicast link-local: fe80::/10
+ if segs[0] & 0xffc0 == 0xfe80 {
+ return Some("IPv6 address is link-local");
+ }
+ }And add test coverage:
#[test]
fn ssrf_blocks_ipv6_mapped_ipv4_private_and_metadata() {
assert!(ssrf_block_reason("http://[::ffff:10.0.0.1]/").is_some());
assert!(ssrf_block_reason("http://[::ffff:192.168.1.1]/").is_some());
assert!(ssrf_block_reason("http://[::ffff:169.254.169.254]/").is_some());
}
#[test]
fn ssrf_blocks_ipv6_link_local_and_unique_local() {
assert!(ssrf_block_reason("http://[fe80::1]/").is_some());
assert!(ssrf_block_reason("http://[fc00::1]/").is_some());
assert!(ssrf_block_reason("http://[fd12:3456:789a::1]/").is_some());
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/workflows/src/lib.rs` around lines 1416 - 1440, The IPv6 branch
currently only checks is_loopback/is_unspecified and skips all IPv4 checks and
IPv6-specific ranges; update the IPv6 handling in ssrf_block_reason to (1)
detect IPv4-mapped IPv6 addresses by calling to_ipv4() on the parsed
std::net::Ipv6Addr and, if Some(ipv4), run the existing IPv4 checks
(is_loopback, is_private, is_link_local, is_unspecified, broadcast, and the
169.254.169.254 metadata check) against that canonical IPv4 before returning;
(2) for native IPv6 addresses, add checks for link-local (check segs[0] & 0xffc0
== 0xfe80) and unique-local (check segs[0] & 0xfe00 == 0xfc00) and return
appropriate block reasons; finally, add the suggested tests
(ssrf_blocks_ipv6_mapped_ipv4_private_and_metadata and
ssrf_blocks_ipv6_link_local_and_unique_local) to exercise these cases.
`crates/workflows/src/lib.rs::run_http_step` accepted any URL from a workflow YAML and sent an HTTP request to it with the workflow runtime's network access. Workflows come from three sources: 1. The user's `~/.prism/workflows/` (user-trusted). 2. Marketplace installs (third-party). 3. LLM-generated workflows during agent execution. #2 and #3 are NOT trusted, but the http step ran identical code. Real attack examples a malicious workflow could trigger: - GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ → exfil AWS instance IAM credentials when running on EC2 - GET http://localhost:7327/api/users → list local PRISM users via the local node's own API - GET http://10.0.0.1:8200/v1/secret/data/... → read from internal Vault if reachable - file:///etc/passwd → read server-side files ## Fix Added `ssrf_block_reason()` that runs before every http step's request. Blocks: - Non-http(s) schemes (file/gopher/dict/data/ftp/...) - Unparseable URLs - Literal hostnames: localhost, ip6-localhost, metadata, metadata.google.internal, ... - Literal IPv4 in loopback / RFC1918 / link-local / broadcast / unspecified ranges - 169.254.169.254 (cloud instance metadata) called out specifically - Literal IPv6 in loopback / unspecified (incl. [::1] form) Returns clear "rejected by SSRF guard: <reason>" error so the workflow author can see why their YAML was refused. What this DOESN'T defend against (documented in the function comment): - DNS rebinding (host resolves to public IP at parse time, then to internal IP at request time). Defending against that needs custom DNS resolution + IP re-check at connect time. - Hosts the attacker controls and points at internal infra. For sensitive networks the right long-term fix is a hostname allowlist in the workflow runtime config; this PR is the obvious- attacks gate that blocks the common payloads. 6 new unit tests cover public-URL allow, localhost block, RFC1918 block, link-local + metadata, dangerous schemes, unparseable. Severity: HIGH — workflows execute with the runtime's network access and any creds it holds (incl. cloud IAM if running on EC2). SSRF is the textbook path from "workflow runs on a server" to "attacker has cloud creds". Same threat-model class as #75/#76/#54. 29 tests pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`crates/workflows/src/lib.rs::run_http_step` accepted any URL from a workflow YAML and sent an HTTP request to it with the workflow runtime's network access. Workflows come from three sources: 1. The user's `~/.prism/workflows/` (user-trusted). 2. Marketplace installs (third-party). 3. LLM-generated workflows during agent execution. #2 and #3 are NOT trusted, but the http step ran identical code. Real attack examples a malicious workflow could trigger: - GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ → exfil AWS instance IAM credentials when running on EC2 - GET http://localhost:7327/api/users → list local PRISM users via the local node's own API - GET http://10.0.0.1:8200/v1/secret/data/... → read from internal Vault if reachable - file:///etc/passwd → read server-side files ## Fix Added `ssrf_block_reason()` that runs before every http step's request. Blocks: - Non-http(s) schemes (file/gopher/dict/data/ftp/...) - Unparseable URLs - Literal hostnames: localhost, ip6-localhost, metadata, metadata.google.internal, ... - Literal IPv4 in loopback / RFC1918 / link-local / broadcast / unspecified ranges - 169.254.169.254 (cloud instance metadata) called out specifically - Literal IPv6 in loopback / unspecified (incl. [::1] form) Returns clear "rejected by SSRF guard: <reason>" error so the workflow author can see why their YAML was refused. What this DOESN'T defend against (documented in the function comment): - DNS rebinding (host resolves to public IP at parse time, then to internal IP at request time). Defending against that needs custom DNS resolution + IP re-check at connect time. - Hosts the attacker controls and points at internal infra. For sensitive networks the right long-term fix is a hostname allowlist in the workflow runtime config; this PR is the obvious- attacks gate that blocks the common payloads. 6 new unit tests cover public-URL allow, localhost block, RFC1918 block, link-local + metadata, dangerous schemes, unparseable. Severity: HIGH — workflows execute with the runtime's network access and any creds it holds (incl. cloud IAM if running on EC2). SSRF is the textbook path from "workflow runs on a server" to "attacker has cloud creds". Same threat-model class as #75/#76/#54. 29 tests pass. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
`crates/workflows/src/lib.rs::run_http_step` accepted any URL from a workflow YAML and sent an HTTP request with the workflow runtime's network access. Workflows come from three sources:
#2 and #3 are not trusted, but the http step ran identical code.
Real attack payloads
```yaml
Exfil AWS IAM credentials when running on EC2
url: http://169.254.169.254/latest/meta-data/iam/security-credentials/
List local PRISM users via the node's own API (no auth needed since
we're calling from inside the node's localhost)
url: http://localhost:7327/api/users
Read internal Vault
url: http://10.0.0.1:8200/v1/secret/data/db-creds
Read server-side files
url: file:///etc/passwd
```
Fix
Added `ssrf_block_reason()` that runs before every http step's request. Blocks:
Returns a clear "rejected by SSRF guard: " error so workflow authors can see why their YAML was refused.
Documented limitations
Doesn't defend against:
For sensitive networks the right long-term fix is a hostname allowlist in the workflow runtime config; this PR closes the obvious payloads.
Severity
HIGH — workflows execute with the runtime's network access and any creds it holds (incl. cloud IAM if running on EC2). SSRF is the textbook path from "workflow runs on a server" to "attacker has cloud creds." Same threat-model class as PR #75 / #76 / #80.
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests