Skip to content

fix(security): SSRF guard on workflow http step — Bug #56 (HIGH) - #81

Merged
Darth-Hidious merged 1 commit into
mainfrom
fix/bug-56-workflow-http-ssrf-guard
May 9, 2026
Merged

fix(security): SSRF guard on workflow http step — Bug #56 (HIGH)#81
Darth-Hidious merged 1 commit into
mainfrom
fix/bug-56-workflow-http-ssrf-guard

Conversation

@Darth-Hidious

@Darth-Hidious Darth-Hidious commented May 9, 2026

Copy link
Copy Markdown
Owner

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:

  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 payloads

```yaml

Exfil AWS IAM credentials when running on EC2

List local PRISM users via the node's own API (no auth needed since

we're calling from inside the node's localhost)

Read internal Vault

Read server-side files

  • action: http
    url: file:///etc/passwd
    ```

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]`)

Returns a clear "rejected by SSRF guard: " error so workflow authors can see why their YAML was refused.

Documented limitations

Doesn't defend against:

  • DNS rebinding (host resolves to public IP at parse time, then to internal IP at request time)
  • Attacker-controlled hosts that point DNS at internal infra

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

  • cargo check -p prism-workflows — clean
  • cargo clippy -p prism-workflows --all-targets -- -D warnings — clean
  • cargo test -p prism-workflows --lib — 29 passed (6 new SSRF tests)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Added security validation for HTTP workflow requests to prevent access to localhost, private networks, and metadata services.
  • Tests

    • Added comprehensive test coverage for HTTP request validation.

Review Change Stack

`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>
@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR adds Server-Side Request Forgery (SSRF) protection to HTTP workflow steps. A new ssrf_block_reason validation function rejects unsafe URLs based on scheme, hostname, and IP address, and run_http_step now enforces this guard before sending requests. Comprehensive tests cover allowed public URLs and denied local/private/metadata targets.

Changes

SSRF Protection for HTTP Workflows

Layer / File(s) Summary
SSRF Guard Implementation
crates/workflows/src/lib.rs
New internal function ssrf_block_reason(url: &str) validates URLs: rejects non-HTTP(S) schemes, empty/unparseable URLs, blocked hostnames (localhost, metadata), and literal IPs (loopback, private, link-local, AWS metadata service 169.254.169.254, and IPv6 unspecified/loopback).
HTTP Request Security
crates/workflows/src/lib.rs
run_http_step now calls ssrf_block_reason before constructing HTTP requests and fails the workflow step with a descriptive bail! error that includes the step id, rejected URL, and rejection reason.
Test Coverage
crates/workflows/src/lib.rs
Unit tests for ssrf_block_reason validate allow cases (public HTTPS/HTTP URLs) and deny cases (localhost, private IPv4 ranges, link-local/metadata hosts, dangerous schemes, unparseable/empty inputs).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A rabbit hops through HTTP streams,
Guarding workflows from SSRF dreams,
No localhost, no metadata spy,
Just safe URLs reaching the sky! 🌐✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(security): SSRF guard on workflow http step — Bug #56 (HIGH)' directly and clearly describes the main security fix in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bug-56-workflow-http-ssrf-guard

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/workflows/src/lib.rs (1)

1917-1961: ⚡ Quick win

Solid 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 to 0.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

📥 Commits

Reviewing files that changed from the base of the PR and between 587580f and 119b351.

📒 Files selected for processing (1)
  • crates/workflows/src/lib.rs

Comment on lines +1416 to +1440
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");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 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 -20

Repository: 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_ssrf

Repository: 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...");
}
EOF

Repository: Darth-Hidious/PRISM

Length of output: 45


🏁 Script executed:

cd crates/workflows && grep -A 20 "fn ssrf_blocks_localhost" src/lib.rs

Repository: 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.rs

Repository: 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.toml

Repository: 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:


🌐 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 -50

Repository: 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.rs

Repository: 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 -20

Repository: 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.rs

Repository: 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:


🏁 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.md

Repository: 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:


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 private
  • http://[::ffff:169.254.169.254]/ → AWS/GCP metadata service
  • http://[fe80::1]/ and any fe80::/10 → IPv6 link-local
  • http://[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.

@Darth-Hidious
Darth-Hidious merged commit de22318 into main May 9, 2026
11 checks passed
Darth-Hidious added a commit that referenced this pull request Jul 3, 2026
`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>
Darth-Hidious added a commit that referenced this pull request Jul 3, 2026
`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>
@Darth-Hidious
Darth-Hidious deleted the fix/bug-56-workflow-http-ssrf-guard branch July 3, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant