diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b1194b1..768e15ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,6 +155,9 @@ jobs: - name: Run clippy run: cargo clippy --no-default-features --features pg${{ matrix.pg_version }} -- -D warnings + - name: Run clippy (no-ssrf-protection) + run: cargo clippy --no-default-features --features pg${{ matrix.pg_version }},no-ssrf-protection -- -D warnings + - name: Run unit tests run: cargo pgrx test pg${{ matrix.pg_version }} diff --git a/Cargo.toml b/Cargo.toml index 3e9b21fc..ef01ea74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ path = "./src/bin/pgrx_embed.rs" [features] default = ["pg17"] +no-ssrf-protection = [] pg13 = ["pgrx/pg13", "pgrx-tests/pg13" ] pg14 = ["pgrx/pg14", "pgrx-tests/pg14" ] pg15 = ["pgrx/pg15", "pgrx-tests/pg15" ] diff --git a/docs/spec-security-model.md b/docs/spec-security-model.md index d1f2f2d4..de3f8a33 100644 --- a/docs/spec-security-model.md +++ b/docs/spec-security-model.md @@ -90,8 +90,27 @@ The security guarantee is: **only superusers can install the extension**, theref ### 3.2 Threats and Mitigations +#### Implementation Priority Summary + +| Threat | Severity | Status | Notes | +|--------|----------|--------|-------| +| **T8**: SSRF via HTTP Activity | **CRITICAL** | Implemented | Dataplane protection — see [spec-ssrf-protection.md](spec-ssrf-protection.md) | +| **T4**: Information Disclosure via df.* Tables | **HIGH** | Not implemented | RLS policies needed | +| **T9**: Unauthorized HTTP Access | **HIGH** | Not implemented | `REVOKE EXECUTE` + admin allowlist (future spec) | +| **T11**: Secret Exfiltration | **HIGH** | Not implemented | Additive feature; no table/API exists yet | +| **T10**: Cross-User Variable Injection | **MEDIUM-HIGH** | Not implemented | Per-user `df.vars` scoping via RLS | +| **T5**: Denial of Service | **MEDIUM** | Not implemented | Rate limiting; deferred | +| **T6**: Worker Code Vulnerability | **MEDIUM** | Mitigated by design | Relies on code review | +| **T0**: SECURITY DEFINER Misuse | **MEDIUM** | Documentation-only | Expected PG behavior | +| **T7**: Extension Trustworthiness | **LOW** | Accepted | Standard PG trust model | +| **T1–T3**: Privilege Escalation | **CRITICAL** | Implemented | Per-user sqlx connections | + +--- + #### T0: SECURITY DEFINER Invocation Captures Definer Privileges +**Severity**: MEDIUM | **Status**: Documentation-only + **Threat**: Calling `df.start()` inside a `SECURITY DEFINER` function captures the definer’s identity (because `GetUserId()`/`current_user` reflect the definer inside the function). Unprivileged callers could cause durable work to run with the definer’s privileges. **Mitigation (documentation-only)**: This is expected PostgreSQL behavior. The extension does **not** block this pattern. Operators must avoid invoking `df.start()` from `SECURITY DEFINER` unless they explicitly want definer-level execution. Document clearly and, if possible, emit audit logs when df is invoked from SECURITY DEFINER. @@ -100,6 +119,8 @@ The security guarantee is: **only superusers can install the extension**, theref #### T1: Privilege Escalation via RESET ROLE +**Severity**: CRITICAL | **Status**: Implemented + **Threat**: User submits SQL containing `RESET ROLE` to escape back to worker's identity. ```sql @@ -118,6 +139,8 @@ SELECT df.start( #### T2: Privilege Escalation via SET ROLE +**Severity**: CRITICAL | **Status**: Implemented + **Threat**: User attempts to assume a more privileged role. ```sql @@ -135,6 +158,8 @@ SELECT df.start( #### T3: Privilege Escalation via Dynamic SQL +**Severity**: CRITICAL | **Status**: Implemented + **Threat**: User obfuscates malicious commands. ```sql @@ -152,7 +177,9 @@ SELECT df.start( #### T4: Information Disclosure via df.* Tables -**Threat**: User queries `df.instances` or `df.nodes` to see other users' durable functions. +**Severity**: HIGH | **Status**: Not implemented + +**Threat**: User queries `df.instances` or `df.nodes` to see other users' durable functions. Without RLS, any user with SELECT access can see all workflows, infer execution patterns, and read metadata belonging to other users. **Mitigation**: Row-Level Security (RLS) on `df.instances` and `df.nodes`: ```sql @@ -170,6 +197,8 @@ ALTER TABLE df.nodes ENABLE ROW LEVEL SECURITY; #### T5: Denial of Service via Resource Exhaustion +**Severity**: MEDIUM | **Status**: Not implemented (deferred) + **Threat**: User creates many long-running durable functions to exhaust worker capacity. **Mitigation**: @@ -183,6 +212,8 @@ ALTER TABLE df.nodes ENABLE ROW LEVEL SECURITY; #### T6: Background Worker Code Vulnerability +**Severity**: MEDIUM | **Status**: Mitigated by design + **Threat**: Bug in extension code allows attacker to control which user the worker connects as. **Attack Vector**: If `login_role` or `submitted_by` values used by `connect_as_user()` are derived from user-controlled data, an attacker could forge them to connect as a different user. @@ -200,6 +231,8 @@ ALTER TABLE df.nodes ENABLE ROW LEVEL SECURITY; #### T7: Extension Code Trustworthiness +**Severity**: LOW (accepted) | **Status**: N/A — inherent to PG extension model + **Threat**: Malicious or buggy extension code abuses its ability to connect as any user. **Context**: PG's extension architecture has a full trust model - extension code must be safe and correct. Any extension can call C functions, which is stronger than "connect as any user". @@ -216,49 +249,45 @@ ALTER TABLE df.nodes ENABLE ROW LEVEL SECURITY; #### T8: Server-Side Request Forgery (SSRF) via HTTP Activity -**Threat**: Attacker uses `df.http()` to access internal services (cloud metadata, internal APIs). +**Severity**: CRITICAL | **Status**: Implemented -```sql --- Attack: Access AWS metadata endpoint -SELECT df.start( - df.http('GET', 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'), - 'ssrf-attack' -); -``` +**Threat**: Attacker uses `df.http()` to access internal network services, cloud metadata endpoints, or localhost services from within the PostgreSQL VM. In a PG-as-a-service deployment, this is a dataplane escape. -**Mitigation**: -- Block private IP ranges by default (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16) -- Block localhost (127.0.0.0/8, ::1) -- DNS rebinding protection: resolve hostname, check IP, then connect -- URL allowlist required — no HTTP requests without explicit permission +**Mitigation (implemented)**: Compile-time IP blocklist that blocks all private/reserved IP ranges, with DNS rebinding protection and IPv4-mapped IPv6 handling. The blocklist is hardcoded and cannot be bypassed by any database user, including superusers. -**Residual Risk**: Low — defense in depth (IP blocking + allowlist). +See [spec-ssrf-protection.md](spec-ssrf-protection.md) for the full specification, blocked IP ranges, and implementation details. + +**Residual Risk**: Low — hardcoded blocklist cannot be bypassed. --- #### T9: Unauthorized HTTP Access +**Severity**: HIGH | **Status**: Not implemented (future spec) + **Threat**: User abuses `df.http()` to access external resources they shouldn't (exfiltrate data, attack external services). -**Mitigation**: +**Mitigation** (to be addressed in a future customer-facing access control spec): - `df.http()` function has EXECUTE permission revoked from PUBLIC by default - DBA grants EXECUTE to roles that need HTTP access - GUC-based URL allowlist (`df.http_allowed_hosts`) for fine-grained control - Rate limiting via GUCs - Audit logging of all HTTP activity -**Residual Risk**: Low — defense in depth (function permission + URL allowlist + SSRF blocking). +**Residual Risk**: Medium until customer-level controls are implemented. T8 (SSRF/dataplane) protection is independent and addressed first. --- #### T10: Cross-User Variable Injection via df.vars +**Severity**: MEDIUM-HIGH | **Status**: Not implemented + **Threat**: `df.vars` is a database table used to pass workflow variables into `df.start()`. In the current design, if `df.vars` is globally writable/readable, one user can: - Override variables that another user expects (integrity issue) - Read variables set by other users (confidentiality issue) -This can lead to wrong SQL/HTTP destinations if graphs use `$var` substitution. +This can lead to wrong SQL/HTTP destinations if graphs use `$var` substitution. An attacker could redirect another user's workflow to an attacker-controlled endpoint by overwriting a variable like `api_endpoint`. **Mitigation (recommended)**: Scope variables per-user using a table key and RLS: @@ -278,7 +307,9 @@ This can lead to wrong SQL/HTTP destinations if graphs use `$var` substitution. #### T11: Secret Exfiltration via df.secrets -**Threat**: `df.secrets` are intended to be admin-managed values (API keys, shared tokens) that workflows can use without hard-coding secrets into graphs. If secrets are directly readable by all users, they are not secrets. +**Severity**: HIGH | **Status**: Not implemented (additive feature) + +**Threat**: `df.secrets` are intended to be admin-managed values (API keys, shared tokens) that workflows can use without hard-coding secrets into graphs. If secrets are directly readable by all users, they are not secrets. Without this feature, users must embed credentials directly in function graphs, where they are stored in `df.nodes` and potentially visible in logs. **Mitigation**: - Secrets MUST NOT be directly selectable by non-admin users @@ -518,120 +549,13 @@ ALTER SYSTEM SET df.http_max_response_bytes = 10485760; -- 10MB ### 6.4 SSRF Protection -**Always-on protections** (cannot be disabled): - -| IP Range | Reason | -|----------|--------| -| `10.0.0.0/8` | Private network (RFC 1918) | -| `172.16.0.0/12` | Private network (RFC 1918) | -| `192.168.0.0/16` | Private network (RFC 1918) | -| `169.254.0.0/16` | Link-local / Cloud metadata | -| `127.0.0.0/8` | Localhost | -| `::1` | IPv6 localhost | -| `fc00::/7` | IPv6 private | +SSRF protection is implemented as a compile-time IP blocklist that blocks all private/reserved IP ranges (RFC 1918, link-local, loopback, IPv6 ULA, etc.), with DNS rebinding protection and IPv4-mapped IPv6 handling. -**DNS rebinding protection**: -```rust -// 1. Resolve hostname to IP -let ip = resolve_dns(&url.host())?; - -// 2. Check IP against blocklist BEFORE connecting -if is_blocked_ip(&ip) { - return Err("SSRF: blocked IP address"); -} - -// 3. Disable redirects by default; if enabled, re-validate every hop -let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build()?; - -// 4. Connect to the resolved IP (not hostname) -let response = client.get(&url).resolve(&url.host(), ip).send()?; - -// If redirects are explicitly enabled later, each redirect must: -// - Resolve the new host, re-check blocklist/allowlist/IP -// - Enforce the same port validation -// - Reject if any hop violates the rules -``` +See [spec-ssrf-protection.md](spec-ssrf-protection.md) for the full specification including blocked ranges, implementation architecture, and testing. ### 6.5 Implementation -```rust -// src/activities/execute_http.rs - -pub async fn execute( - ctx: ActivityContext, - security_ctx: SecurityContext, - method: String, - url: String, - headers: Option>, - body: Option, -) -> Result { - // Note: Function-level permission already checked by PostgreSQL - // before df.http() could be called in df.start() - - // 1. Parse and validate URL - // Implementation Note: reqwest MUST use rustls-tls to avoid OpenSSL conflicts with Postgres - // Cargo.toml: reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"] } - let parsed_url = Url::parse(&url) - .map_err(|e| format!("Invalid URL: {}", e))?; - - // 2. SSRF Protection - resolve DNS and check IP - let ip = resolve_dns(parsed_url.host_str().unwrap_or(""))?; - if is_ssrf_blocked_ip(&ip) { - return Err(format!( - "HTTP request blocked: {} resolves to internal IP {}", - parsed_url.host_str().unwrap_or(""), ip - )); - } - - // 3. Check URL allowlist (GUC: df.http_allowed_hosts) - if !is_host_allowed(&parsed_url) { - return Err(format!( - "HTTP request blocked: host '{}' not in allowed list. \ - Configure df.http_allowed_hosts to allow this host.", - parsed_url.host_str().unwrap_or("") - )); - } - - // 4. Redirect policy: disabled by default; if ever enabled, every hop must re-check SSRF + allowlist + port - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| format!("Failed to build HTTP client: {}", e))?; - - // 5. Rate limiting - check_rate_limit(&security_ctx.user_name).await?; - - // 6. Execute request with timeout - let timeout = get_guc_int("df.http_timeout_seconds", 30); - let response = execute_http_request(method, parsed_url, ip, headers, body, timeout, client).await?; - - // 7. Log for audit - log_http_request(&security_ctx, &url, &method, response.status()); - - Ok(response.to_json()) -} - -fn is_ssrf_blocked_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(ipv4) => { - ipv4.is_private() || // 10.x, 172.16-31.x, 192.168.x - ipv4.is_loopback() || // 127.x - ipv4.is_link_local() || // 169.254.x (cloud metadata!) - ipv4.is_broadcast() || - ipv4.is_documentation() || - ipv4.is_unspecified() - } - IpAddr::V6(ipv6) => { - ipv6.is_loopback() || // ::1 - ipv6.is_unspecified() || - // IPv6 private ranges - is_ipv6_private(ipv6) - } - } -} -``` +See [spec-ssrf-protection.md](spec-ssrf-protection.md) for the SSRF implementation details. The access control layers (URL allowlist, rate limiting) described in Section 6.1 are not yet implemented. ### 6.6 User Experience diff --git a/docs/spec-ssrf-protection.md b/docs/spec-ssrf-protection.md new file mode 100644 index 00000000..583c569a --- /dev/null +++ b/docs/spec-ssrf-protection.md @@ -0,0 +1,396 @@ +# Spec: SSRF Protection for `df.http()` + +**Status**: Completed +**Threat**: T8 in [spec-security-model.md](spec-security-model.md) +**Severity**: CRITICAL + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Threat Model](#2-threat-model) +3. [Design Decisions](#3-design-decisions) +4. [Two-Layer Security Model](#4-two-layer-security-model) +5. [Dataplane Protection (This Spec)](#5-dataplane-protection-this-spec) +6. [Implementation](#6-implementation) +7. [Testing](#7-testing) +8. [Out of Scope](#8-out-of-scope) + +--- + +## 1. Overview + +`df.http()` allows durable functions to make HTTP requests from within the PostgreSQL background worker. In a PG-as-a-service deployment, this creates a dataplane escape vector: a malicious customer can probe or attack the hosting infrastructure's local network (cloud metadata endpoints, internal APIs, localhost services). + +This spec defines the **dataplane protection layer** — a compile-time IP blocklist that prevents HTTP requests to private/internal network addresses. This layer is hardcoded and cannot be bypassed by any database user, including superusers. + +--- + +## 2. Threat Model + +### Attack Scenarios + +**Cloud metadata exfiltration:** +```sql +SELECT df.start( + df.http('http://169.254.169.254/latest/meta-data/iam/security-credentials/', 'GET'), + 'steal-creds' +); +``` + +**Localhost service probing:** +```sql +SELECT df.start( + df.http('http://127.0.0.1:8500/v1/agent/members', 'GET'), + 'probe-consul' +); +``` + +**Internal network scanning:** +```sql +SELECT df.start( + df.http('http://10.0.0.1:9090/api/v1/targets', 'GET'), + 'probe-prometheus' +); +``` + +**IPv4-mapped IPv6 bypass:** +```sql +-- Same as 169.254.169.254 but via IPv6 notation +SELECT df.start( + df.http('http://[::ffff:169.254.169.254]/latest/meta-data/', 'GET'), + 'ipv6-bypass' +); +``` + +### Impact + +A successful SSRF attack from within the PG dataplane can: +- Steal cloud instance credentials (IAM roles, managed identity tokens) +- Access internal service discovery and configuration +- Pivot to internal services not exposed to the internet +- Exfiltrate customer data to attacker-controlled endpoints (addressed by T9, not this spec) + +--- + +## 3. Design Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| D1 | Two layers: compile-time dataplane + future customer-level controls | Dataplane protection is non-negotiable and must not be bypassable. Customer-level controls (allowlists, REVOKE) are a separate concern. | +| D2 | HTTP and HTTPS only — block all other schemes | `file://`, `ftp://`, `gopher://` etc. have no legitimate use case and expand the attack surface. | +| D3 | Port restrictions: out of scope for now | All ports are allowed for HTTP/HTTPS. Port-based restrictions may be added in the customer-level spec. | +| D4 | Handle IPv4-mapped IPv6 (`::ffff:A.B.C.D`) | Must extract the embedded IPv4 address and check it against the blocklist. | +| D5 | Check only the selected IP, not all A records | DNS may return multiple addresses. Only the one `reqwest` actually connects to needs to pass the blocklist. Document this explicitly. | +| D6 | No DNS domain allowlist at this layer | Allowlists are a customer-level concern. The dataplane layer only blocks; it never allows based on domain. | +| D7 | Rate limiting: deferred | Deferred. | +| D8 | Response size limits: deferred | Deferred. | +| D9 | Log `submitted_by` and `login_role` for HTTP requests | Audit trail for who initiated the request. | + +--- + +## 4. Two-Layer Security Model + +``` +┌──────────────────────────────────────────────────────────┐ +│ Layer 1: Dataplane Protection │ +│ (this spec) │ +│ │ +│ • Cargo feature: no-ssrf-protection (opt-in to disable) │ +│ • Blocks private/reserved IP ranges │ +│ • Cannot be bypassed by superusers or GUCs │ +│ • Protects the hosting infrastructure │ +│ │ +├──────────────────────────────────────────────────────────┤ +│ Layer 2: Customer-Level Controls │ +│ (future spec) │ +│ │ +│ • REVOKE EXECUTE on df.http() from PUBLIC │ +│ • URL/domain allowlists (GUC or table) │ +│ • Per-role HTTP permissions │ +│ • Rate limiting │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +Layer 1 runs **inside** the HTTP activity, before the request is sent. It is always active unless the `no-ssrf-protection` feature is explicitly compiled in. There is no GUC, no table, no superuser override. + +Layer 2 is orthogonal and additive. It will be specified separately and can be configured by database administrators. + +--- + +## 5. Dataplane Protection (This Spec) + +### 5.1 Scheme Validation + +Only `http://` and `https://` schemes are permitted. All other schemes are rejected **before** any DNS resolution or connection attempt. + +Reject with: `"Blocked: unsupported URL scheme '{scheme}'. Only http and https are allowed."` + +### 5.2 Blocked IP Ranges + +After DNS resolution, the resolved IP address is checked against these CIDR ranges: + +| CIDR | Description | +|------|-------------| +| `127.0.0.0/8` | IPv4 loopback | +| `::1/128` | IPv6 loopback | +| `10.0.0.0/8` | RFC 1918 private | +| `172.16.0.0/12` | RFC 1918 private | +| `192.168.0.0/16` | RFC 1918 private | +| `169.254.0.0/16` | Link-local (includes cloud metadata at `169.254.169.254`) | +| `fe80::/10` | IPv6 link-local | +| `fc00::/7` | IPv6 unique local address (ULA) | +| `0.0.0.0/8` | "This" network | +| `::/128` | Unspecified address | + +### 5.3 IPv4-Mapped IPv6 Handling + +IPv4-mapped IPv6 addresses (`::ffff:A.B.C.D`) must be recognized and the embedded IPv4 address extracted before checking against the blocklist. For example: + +- `::ffff:127.0.0.1` → extract `127.0.0.1` → blocked (loopback) +- `::ffff:169.254.169.254` → extract `169.254.169.254` → blocked (link-local) +- `::ffff:10.0.0.1` → extract `10.0.0.1` → blocked (RFC 1918) +- `::ffff:93.184.216.34` → extract `93.184.216.34` → allowed (public) + +### 5.4 DNS Resolution and IP Check + +The protection follows this sequence: + +``` +URL received + │ + ├─ Parse scheme → reject if not http/https + │ + ├─ Extract hostname + │ + ├─ Resolve hostname via DNS → get list of IPs + │ + ├─ reqwest selects one IP to connect to + │ + ├─ Check selected IP against blocklist + │ ├─ If IPv4-mapped IPv6: extract IPv4, check IPv4 blocklist + │ └─ If blocked: reject request + │ + └─ Send request +``` + +**Important**: Only the single IP address that `reqwest` actually connects to is checked. If DNS returns multiple A/AAAA records, the others are not checked because they are never used. This is documented behavior, not a gap — checking unused IPs would create false positives without security benefit. + +#### DNS Rebinding Protection + +A DNS rebinding attack works by returning a public IP on first lookup (passing the blocklist check) and a private IP on a subsequent lookup (used for the actual connection). To prevent this: + +- DNS resolution and the IP blocklist check must happen on the **same resolved address** that is used for the connection. +- The implementation must use a custom `reqwest` `resolve` strategy or a connect callback that intercepts the resolved IP before the TCP connection is established, ensuring the blocklist check and the connection use the same IP. +- Caching DNS results and checking them separately from the connection is **not sufficient** — the check must be inline with the connect path. + +### 5.5 Cargo Feature Gate + +```toml +[features] +default = ["pg17"] +no-ssrf-protection = [] +``` + +SSRF protection is **on by default** — no feature flag needed. The `no-ssrf-protection` feature is an opt-in escape hatch. When compiled with it (e.g., for local development or testing), the IP blocklist is empty (all checks return "allowed") while all code paths remain compiled and exercised. + +The blocklist contents are gated with `#[cfg(not(feature = "no-ssrf-protection"))]` inside the check functions: +- Default (feature absent): IP blocklist is enforced, no override possible. +- With `no-ssrf-protection` enabled: blocklist is empty, all URLs are allowed (development/testing only). + +### 5.6 Error Messages + +When a request is blocked, return a clear error without leaking internal network topology: + +- Scheme violation: `"Blocked: unsupported URL scheme '{scheme}'. Only http and https are allowed."` +- IP blocklist: `"Blocked: the resolved IP address for '{hostname}' is in a restricted range. df.http() cannot access private or internal network addresses."` + +Do **not** include the resolved IP in the error message — this would leak infrastructure details to a potentially malicious user. + +### 5.7 Audit Logging + +All HTTP requests (both allowed and blocked) must be logged with: + +- `submitted_by`: the role that called `df.start()` +- `login_role`: the authenticated connection role +- `url`: the requested URL +- `blocked`: whether the request was blocked by SSRF protection +- `reason`: if blocked, the reason (scheme/IP range) + +These fields are already available on `FunctionNode` (`submitted_by`, `login_role`) and must be threaded through to the HTTP activity. + +--- + +## 6. Implementation + +### 6.1 New Module: `src/ssrf.rs` + +Create a module with the blocklist validation logic: + +```rust +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +/// Check if an IP address is in a blocked range. +/// Returns Some(reason) if blocked, None if allowed. +#[cfg(not(feature = "no-ssrf-protection"))] +pub fn check_blocked_ip(ip: IpAddr) -> Option<&'static str> { + // Handle IPv4-mapped IPv6: extract the embedded IPv4 + let ip = match ip { + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + IpAddr::V4(v4) + } else { + IpAddr::V6(v6) + } + } + other => other, + }; + + match ip { + IpAddr::V4(v4) => check_blocked_ipv4(v4), + IpAddr::V6(v6) => check_blocked_ipv6(v6), + } +} + +fn check_blocked_ipv4(ip: Ipv4Addr) -> Option<&'static str> { + let octets = ip.octets(); + match octets { + [127, ..] => Some("loopback"), + [10, ..] => Some("private (10.0.0.0/8)"), + [172, b, ..] if (16..=31).contains(&b) => Some("private (172.16.0.0/12)"), + [192, 168, ..] => Some("private (192.168.0.0/16)"), + [169, 254, ..] => Some("link-local"), + [0, ..] => Some("reserved (0.0.0.0/8)"), + _ => None, + } +} + +fn check_blocked_ipv6(ip: Ipv6Addr) -> Option<&'static str> { + if ip.is_loopback() { + return Some("loopback (::1)"); + } + let segments = ip.segments(); + if segments[0] & 0xffc0 == 0xfe80 { + return Some("link-local (fe80::/10)"); + } + if segments[0] & 0xfe00 == 0xfc00 { + return Some("unique local (fc00::/7)"); + } + if ip.is_unspecified() { + return Some("unspecified (::)"); + } + None +} +``` + +### 6.2 DNS Resolution with Inline Check + +Use `reqwest`'s `resolve` callback or `hickory-dns` to perform DNS resolution and check the IP before the connection: + +```rust +use reqwest::dns::{Resolve, Resolving, Addrs}; +use std::net::SocketAddr; + +struct SsrfSafeResolver { + inner: Arc, +} + +impl Resolve for SsrfSafeResolver { + fn resolve(&self, name: hyper::client::connect::dns::Name) -> Resolving { + let inner = self.inner.clone(); + Box::pin(async move { + let addrs = inner.resolve(name).await?; + let filtered: Vec = addrs + .filter(|addr| check_blocked_ip(addr.ip()).is_none()) + .collect(); + if filtered.is_empty() { + return Err("all resolved IPs are in blocked ranges".into()); + } + Ok(Box::new(filtered.into_iter()) as Addrs) + }) + } +} +``` + +> **Note**: The exact integration point depends on `reqwest` 0.12's DNS resolver API. The implementation may need to use a `tower` layer or connect callback instead. The key requirement is that the IP check happens **after** DNS resolution and **before** TCP connect, on the same address. + +### 6.3 Changes to `execute_http.rs` + +The activity gains three changes: + +1. **Scheme check** before building the client (always, regardless of feature flag). +2. **SSRF-safe resolver** injected into the `reqwest::Client::builder()` (when feature enabled). +3. **Audit log fields** (`submitted_by`, `login_role`) passed through from `FunctionNode` and logged. + +### 6.4 Changes to `HttpConfig` + +Add audit context fields: + +```rust +pub struct HttpConfig { + pub url: String, + pub method: String, + pub body: Option, + pub headers: Option, + pub timeout_seconds: u64, + // Audit context (populated from FunctionNode) + pub submitted_by: Option, + pub login_role: Option, +} +``` + +These are populated when building the `HttpConfig` from the `FunctionNode` in the orchestration, not by the user's DSL call. + +--- + +## 7. Testing + +### 7.1 Unit Tests + +Test `check_blocked_ip()` against all blocked ranges: + +- All RFC 1918 ranges (10.x, 172.16-31.x, 192.168.x) +- Loopback (127.0.0.1, ::1) +- Link-local (169.254.169.254) +- IPv4-mapped IPv6 variants of all the above +- Public IPs that must be allowed (e.g., 8.8.8.8, 93.184.216.34) +- Edge cases: 172.15.255.255 (allowed), 172.16.0.0 (blocked), 172.31.255.255 (blocked), 172.32.0.0 (allowed) + +### 7.2 E2E Tests + +Create a test that verifies blocked requests fail with the expected error message: + +```sql +-- Test: SSRF protection blocks link-local addresses +SELECT df.start( + df.http('http://169.254.169.254/latest/meta-data/', 'GET'), + 'test-ssrf-blocked' +); +-- Poll until failed, verify error contains "restricted range" +``` + +### 7.3 Feature Flag Test + +Verify that building with `no-ssrf-protection` allows all IPs (for development): + +```bash +cargo build --features pg17,no-ssrf-protection +``` + +--- + +## 8. Out of Scope + +These items are explicitly deferred to the customer-level access control spec (Layer 2): + +| Item | Rationale | +|------|-----------| +| URL/domain allowlists | Customer policy, not infrastructure protection | +| `REVOKE EXECUTE` on `df.http()` | Standard PG permission model, not SSRF-specific | +| Per-role HTTP permissions | Customer policy | +| Rate limiting | DoS mitigation, not SSRF | +| Response size limits | Resource management, not SSRF | +| Port restrictions | Low value at dataplane layer; all ports may host legitimate services | +| Egress filtering (block outbound to attacker domains) | Addressed by T9 | diff --git a/src/activities/execute_http.rs b/src/activities/execute_http.rs index 0532fc66..db702a83 100644 --- a/src/activities/execute_http.rs +++ b/src/activities/execute_http.rs @@ -1,4 +1,7 @@ //! ExecuteHTTP activity - makes HTTP requests +//! +//! SSRF protection is enabled by default. To disable it, compile with the +//! `no-ssrf-protection` Cargo feature. See src/ssrf.rs. use duroxide::ActivityContext; use std::time::Duration; @@ -8,19 +11,63 @@ use crate::types::HttpConfig; /// Activity name for registration and scheduling pub const NAME: &str = "pg_durable::activity::execute-http"; +/// Build a reqwest Client with optional SSRF-safe DNS resolver. +/// +/// Redirects are disabled to prevent redirect-based SSRF bypasses: an attacker +/// could host a 302 redirecting to `http://169.254.169.254/...`, and reqwest +/// would follow it without calling our DNS resolver (since the target is an IP +/// literal). +fn build_client(timeout: Duration) -> Result { + let builder = reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()); + + let builder = { + use crate::ssrf::{SsrfSafeResolver, SystemResolver}; + use std::sync::Arc; + let resolver = SsrfSafeResolver::wrapping(Arc::new(SystemResolver)); + builder.dns_resolver(Arc::new(resolver)) + }; + + builder + .build() + .map_err(|e| format!("Failed to create HTTP client: {e}")) +} + /// Execute an HTTP request and return the response as JSON pub async fn execute(ctx: ActivityContext, config_json: String) -> Result { let config: HttpConfig = serde_json::from_str(&config_json).map_err(|e| format!("Invalid HTTP config: {e}"))?; + // Audit context + let audit_user = config.submitted_by.as_deref().unwrap_or("unknown"); + let audit_login = config.login_role.as_deref().unwrap_or("unknown"); + + // --- Scheme validation (always enforced, regardless of feature flag) --- + crate::ssrf::validate_url_scheme(&config.url).inspect_err(|_| { + ctx.trace_info(format!( + "HTTP BLOCKED (scheme) url={} submitted_by={audit_user} login_role={audit_login}", + config.url + )); + })?; + + // --- IP-literal check (catches http://169.254.169.254 etc., where reqwest + // skips DNS resolution and our resolver never runs) --- + crate::ssrf::validate_url_host(&config.url).inspect_err(|_| { + ctx.trace_info(format!( + "HTTP BLOCKED (ip) url={} submitted_by={audit_user} login_role={audit_login}", + config.url + )); + })?; + let start = std::time::Instant::now(); - ctx.trace_info(format!("HTTP {} {}", config.method, config.url)); + ctx.trace_info(format!( + "HTTP {} {} submitted_by={audit_user} login_role={audit_login}", + config.method, config.url + )); - // Build client with timeout - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(config.timeout_seconds)) - .build() - .map_err(|e| format!("Failed to create HTTP client: {e}"))?; + // Build client with SSRF-safe resolver (when feature enabled) and timeout + let client = build_client(Duration::from_secs(config.timeout_seconds))?; // Build request based on method let mut request = match config.method.as_str() { @@ -50,6 +97,18 @@ pub async fn execute(ctx: ActivityContext, config_json: String) -> Result Option<&'static str> { + // Handle IPv4-mapped IPv6 (::ffff:A.B.C.D) — extract the embedded IPv4 + let ip = match ip { + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => IpAddr::V4(v4), + None => IpAddr::V6(v6), + }, + other => other, + }; + + match ip { + IpAddr::V4(v4) => check_blocked_ipv4(v4), + IpAddr::V6(v6) => check_blocked_ipv6(v6), + } +} + +fn check_blocked_ipv4(ip: Ipv4Addr) -> Option<&'static str> { + #[cfg(feature = "no-ssrf-protection")] + { + let _ = ip; + None + } + #[cfg(not(feature = "no-ssrf-protection"))] + { + let octets = ip.octets(); + match octets { + [0, ..] => Some("reserved (0.0.0.0/8)"), + [10, ..] => Some("private (10.0.0.0/8)"), + [127, ..] => Some("loopback (127.0.0.0/8)"), + [169, 254, ..] => Some("link-local (169.254.0.0/16)"), + [172, b, ..] if (16..=31).contains(&b) => Some("private (172.16.0.0/12)"), + [192, 168, ..] => Some("private (192.168.0.0/16)"), + _ => None, + } + } +} + +fn check_blocked_ipv6(ip: Ipv6Addr) -> Option<&'static str> { + #[cfg(feature = "no-ssrf-protection")] + { + let _ = ip; + None + } + #[cfg(not(feature = "no-ssrf-protection"))] + { + if ip.is_unspecified() { + return Some("unspecified (::)"); + } + if ip.is_loopback() { + return Some("loopback (::1)"); + } + let segments = ip.segments(); + // fe80::/10 — IPv6 link-local + if segments[0] & 0xffc0 == 0xfe80 { + return Some("link-local (fe80::/10)"); + } + // fc00::/7 — IPv6 unique local address + if segments[0] & 0xfe00 == 0xfc00 { + return Some("unique local (fc00::/7)"); + } + None + } +} + +/// Validate a URL scheme. Only `http` and `https` are permitted. +/// Returns `Err` with a user-facing message if the scheme is disallowed. +pub fn validate_url_scheme(url: &str) -> Result<(), String> { + let scheme = url.split("://").next().unwrap_or("").to_ascii_lowercase(); + match scheme.as_str() { + "http" | "https" => Ok(()), + other => Err(format!( + "Blocked: unsupported URL scheme '{other}'. Only http and https are allowed." + )), + } +} + +/// When the URL host is an IP literal (e.g. `http://169.254.169.254/...` or +/// `http://[::1]/...`), check it against the blocklist *before* reqwest sees the +/// URL. reqwest does NOT call the DNS resolver for IP literals, so the +/// resolver-based check alone is insufficient. +/// +/// Returns `Ok(())` if the host is a hostname (will be checked by the resolver) +/// or a non-blocked IP. Returns `Err` if the IP is in a blocked range. +pub fn validate_url_host(url: &str) -> Result<(), String> { + // Strip scheme + let after_scheme = match url.find("://") { + Some(i) => &url[i + 3..], + None => return Ok(()), + }; + // Strip path/query — isolate authority (host + optional port) + let authority = after_scheme.split('/').next().unwrap_or(after_scheme); + // Strip userinfo (user:pass@) + let host_port = match authority.rfind('@') { + Some(i) => &authority[i + 1..], + None => authority, + }; + // Extract host, handling bracketed IPv6 like [::1]:8080 + let host = if host_port.starts_with('[') { + // IPv6 literal in brackets + match host_port.find(']') { + Some(i) => &host_port[1..i], + None => return Ok(()), // malformed, let reqwest handle it + } + } else { + // IPv4 or hostname — strip port + match host_port.rfind(':') { + Some(i) => &host_port[..i], + None => host_port, + } + }; + + // Try to parse as IP address. If it's a hostname, return Ok — the + // SsrfSafeResolver will check it after DNS resolution. + if let Ok(ip) = host.parse::() { + if check_blocked_ip(ip).is_some() { + return Err(format!( + "Blocked: the resolved IP address for '{}' is in a restricted \ + range. df.http() cannot access private or internal network addresses.", + host + )); + } + } + Ok(()) +} + +// Keep this marker in sync with the error message in SsrfSafeResolver::resolve(). +const SSRF_BLOCK_MARKER: &str = "Blocked:"; +const SSRF_RESTRICTED_MARKER: &str = "restricted"; + +/// Returns `true` if `err_msg` looks like an SSRF IP-blocklist rejection +/// produced by [`SsrfSafeResolver`]. Both marker strings are defined here, +/// next to the resolver that emits them, so changes stay in sync. +pub fn is_ssrf_block_error(err_msg: &str) -> bool { + err_msg.contains(SSRF_BLOCK_MARKER) && err_msg.contains(SSRF_RESTRICTED_MARKER) +} + +// --------------------------------------------------------------------------- +// SSRF-safe DNS resolver — wraps the default resolver and filters out blocked IPs +// --------------------------------------------------------------------------- + +mod resolver { + use super::check_blocked_ip; + use reqwest::dns::{Addrs, Name, Resolve, Resolving}; + use std::sync::Arc; + + /// A DNS resolver wrapper that filters blocked IPs from resolution results. + /// This ensures the blocklist check and the connection use the same address, + /// preventing DNS rebinding attacks. + pub struct SsrfSafeResolver { + inner: Arc, + } + + impl SsrfSafeResolver { + pub fn wrapping(inner: Arc) -> Self { + Self { inner } + } + } + + impl Resolve for SsrfSafeResolver { + fn resolve(&self, name: Name) -> Resolving { + let hostname = name.as_str().to_owned(); + let inner_future = self.inner.resolve(name); + Box::pin(async move { + let addrs = inner_future.await?; + let filtered: Vec = addrs + .filter(|addr| check_blocked_ip(addr.ip()).is_none()) + .collect(); + if filtered.is_empty() { + return Err(format!( + "Blocked: the resolved IP address for '{hostname}' is in a restricted \ + range. df.http() cannot access private or internal network addresses." + ) + .into()); + } + Ok(Box::new(filtered.into_iter()) as Addrs) + }) + } + } +} + +pub use resolver::SsrfSafeResolver; + +// --------------------------------------------------------------------------- +// Default (system) DNS resolver — needed as the "inner" for SsrfSafeResolver +// --------------------------------------------------------------------------- + +mod system_resolver { + use reqwest::dns::{Addrs, Name, Resolve, Resolving}; + use std::net::ToSocketAddrs; + + /// Simple blocking DNS resolver that delegates to the OS via `ToSocketAddrs`. + pub struct SystemResolver; + + impl Resolve for SystemResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_owned(); + Box::pin(async move { + let host_port = format!("{host}:0"); + let addrs: Vec = + tokio::task::spawn_blocking(move || host_port.to_socket_addrs()) + .await + .map_err(|e| -> Box { Box::new(e) })? + .map_err(|e| -> Box { Box::new(e) })? + .collect(); + Ok(Box::new(addrs.into_iter()) as Addrs) + }) + } + } +} + +pub use system_resolver::SystemResolver; + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + // --- IPv4 blocked ranges --- + + #[test] + fn blocks_loopback() { + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))).is_some()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255))).is_some()); + } + + #[test] + fn blocks_rfc1918_10() { + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0))).is_some()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(10, 255, 255, 255))).is_some()); + } + + #[test] + fn blocks_rfc1918_172() { + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 0))).is_some()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255))).is_some()); + // Edge: 172.15.x.x is NOT private + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 15, 255, 255))).is_none()); + // Edge: 172.32.x.x is NOT private + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(172, 32, 0, 0))).is_none()); + } + + #[test] + fn blocks_rfc1918_192_168() { + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 0))).is_some()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 255, 255))).is_some()); + } + + #[test] + fn blocks_link_local() { + // Cloud metadata endpoint + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))).is_some()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 0, 0))).is_some()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 255, 255))).is_some()); + } + + #[test] + fn blocks_this_network() { + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))).is_some()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(0, 255, 255, 255))).is_some()); + } + + // --- IPv4 allowed (public) --- + + #[test] + fn allows_public_ipv4() { + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))).is_none()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))).is_none()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))).is_none()); + assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))).is_none()); + } + + // --- IPv6 blocked ranges --- + + #[test] + fn blocks_ipv6_loopback() { + assert!(check_blocked_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)).is_some()); + } + + #[test] + fn blocks_ipv6_unspecified() { + assert!(check_blocked_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED)).is_some()); + } + + #[test] + fn blocks_ipv6_link_local() { + assert!(check_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))).is_some()); + } + + #[test] + fn blocks_ipv6_ula() { + assert!(check_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1))).is_some()); + assert!(check_blocked_ip(IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1))).is_some()); + } + + // --- IPv6 allowed (public) --- + + #[test] + fn allows_public_ipv6() { + // Google DNS + assert!(check_blocked_ip(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888 + ))) + .is_none()); + } + + // --- IPv4-mapped IPv6 --- + + #[test] + fn blocks_ipv4_mapped_ipv6_loopback() { + // ::ffff:127.0.0.1 + let ip: IpAddr = "::ffff:127.0.0.1".parse().unwrap(); + assert!(check_blocked_ip(ip).is_some()); + } + + #[test] + fn blocks_ipv4_mapped_ipv6_link_local() { + // ::ffff:169.254.169.254 (cloud metadata) + let ip: IpAddr = "::ffff:169.254.169.254".parse().unwrap(); + assert!(check_blocked_ip(ip).is_some()); + } + + #[test] + fn blocks_ipv4_mapped_ipv6_private() { + let ip: IpAddr = "::ffff:10.0.0.1".parse().unwrap(); + assert!(check_blocked_ip(ip).is_some()); + let ip: IpAddr = "::ffff:192.168.1.1".parse().unwrap(); + assert!(check_blocked_ip(ip).is_some()); + let ip: IpAddr = "::ffff:172.16.0.1".parse().unwrap(); + assert!(check_blocked_ip(ip).is_some()); + } + + #[test] + fn allows_ipv4_mapped_ipv6_public() { + // ::ffff:93.184.216.34 + let ip: IpAddr = "::ffff:93.184.216.34".parse().unwrap(); + assert!(check_blocked_ip(ip).is_none()); + } + + // --- URL scheme validation --- + + #[test] + fn allows_http_https() { + assert!(validate_url_scheme("http://example.com").is_ok()); + assert!(validate_url_scheme("https://example.com").is_ok()); + assert!(validate_url_scheme("HTTP://EXAMPLE.COM").is_ok()); + assert!(validate_url_scheme("HTTPS://example.com").is_ok()); + } + + #[test] + fn blocks_file_scheme() { + assert!(validate_url_scheme("file:///etc/passwd").is_err()); + } + + #[test] + fn blocks_ftp_scheme() { + assert!(validate_url_scheme("ftp://ftp.example.com").is_err()); + } + + #[test] + fn blocks_gopher_scheme() { + assert!(validate_url_scheme("gopher://evil.com").is_err()); + } + + #[test] + fn blocks_empty_and_malformed() { + assert!(validate_url_scheme("").is_err()); + assert!(validate_url_scheme("no-scheme").is_err()); + } + + // --- URL host (IP literal) validation --- + + #[test] + fn host_blocks_ipv4_literals() { + assert!(validate_url_host("http://169.254.169.254/metadata").is_err()); + assert!(validate_url_host("http://127.0.0.1:8080/path").is_err()); + assert!(validate_url_host("https://10.0.0.1/admin").is_err()); + assert!(validate_url_host("http://192.168.1.1").is_err()); + assert!(validate_url_host("http://172.16.0.1:443/").is_err()); + } + + #[test] + fn host_blocks_ipv6_literals() { + assert!(validate_url_host("http://[::1]/path").is_err()); + assert!(validate_url_host("http://[::1]:8080/path").is_err()); + assert!(validate_url_host("http://[fe80::1]/path").is_err()); + assert!(validate_url_host("http://[::ffff:169.254.169.254]/meta").is_err()); + assert!(validate_url_host("http://[::ffff:127.0.0.1]:80/").is_err()); + } + + #[test] + fn host_allows_public_ips() { + assert!(validate_url_host("http://8.8.8.8/dns").is_ok()); + assert!(validate_url_host("https://93.184.216.34/page").is_ok()); + assert!(validate_url_host("http://[2001:4860:4860::8888]/dns").is_ok()); + } + + #[test] + fn host_allows_hostnames() { + // Hostnames are not checked here — the resolver handles them + assert!(validate_url_host("http://example.com/path").is_ok()); + assert!(validate_url_host("https://internal.corp:8443/api").is_ok()); + } +} diff --git a/src/types.rs b/src/types.rs index 587f25c5..d8fd92ac 100644 --- a/src/types.rs +++ b/src/types.rs @@ -385,6 +385,12 @@ pub struct HttpConfig { pub headers: Option, #[serde(default = "default_http_timeout")] pub timeout_seconds: u64, + /// Role that called df.start() (audit trail) + #[serde(default)] + pub submitted_by: Option, + /// Authenticated connection role (audit trail) + #[serde(default)] + pub login_role: Option, } fn default_http_timeout() -> u64 { diff --git a/tests/e2e/sql/36_ssrf_protection.sql b/tests/e2e/sql/36_ssrf_protection.sql new file mode 100644 index 00000000..671c58db --- /dev/null +++ b/tests/e2e/sql/36_ssrf_protection.sql @@ -0,0 +1,153 @@ +-- E2E Test: SSRF Protection for df.http() +-- Tests that HTTP requests to private/reserved IP ranges are blocked. +-- Spec: docs/spec-ssrf-protection.md + +-- ============================================================================ +-- Test 1: Block cloud metadata endpoint (link-local 169.254.169.254) +-- ============================================================================ + +CREATE TEMP TABLE _test_ssrf1 (instance_id TEXT); + +INSERT INTO _test_ssrf1 SELECT df.start( + df.http('http://169.254.169.254/latest/meta-data/', 'GET'), + 'test-ssrf-metadata' +); + +DO $$ +DECLARE + inst_id TEXT; + status TEXT; + node_result TEXT; +BEGIN + SELECT instance_id INTO inst_id FROM _test_ssrf1; + RAISE NOTICE 'Testing SSRF block (metadata endpoint): %', inst_id; + + SELECT df.wait_for_completion(inst_id) INTO status; + + IF status != 'failed' THEN + RAISE EXCEPTION 'TEST FAILED: SSRF metadata request should have failed, got status = %', status; + END IF; + + -- Verify the error mentions restricted range + SELECT result::text INTO node_result + FROM df.nodes + WHERE instance_id = inst_id AND node_type = 'HTTP'; + + IF node_result IS NULL OR node_result NOT ILIKE '%restricted%' THEN + RAISE EXCEPTION 'TEST FAILED: expected "restricted" in error, got: %', node_result; + END IF; + + RAISE NOTICE 'TEST PASSED: ssrf_block_metadata'; +END $$; + +DROP TABLE _test_ssrf1; + +-- ============================================================================ +-- Test 2: Block localhost (127.0.0.1) +-- ============================================================================ + +CREATE TEMP TABLE _test_ssrf2 (instance_id TEXT); + +INSERT INTO _test_ssrf2 SELECT df.start( + df.http('http://127.0.0.1:9999/probe', 'GET'), + 'test-ssrf-localhost' +); + +DO $$ +DECLARE + inst_id TEXT; + status TEXT; + node_result TEXT; +BEGIN + SELECT instance_id INTO inst_id FROM _test_ssrf2; + RAISE NOTICE 'Testing SSRF block (localhost): %', inst_id; + + SELECT df.wait_for_completion(inst_id) INTO status; + + IF status != 'failed' THEN + RAISE EXCEPTION 'TEST FAILED: SSRF localhost request should have failed, got status = %', status; + END IF; + + SELECT result::text INTO node_result + FROM df.nodes + WHERE instance_id = inst_id AND node_type = 'HTTP'; + + IF node_result IS NULL OR node_result NOT ILIKE '%restricted%' THEN + RAISE EXCEPTION 'TEST FAILED: expected "restricted" in error, got: %', node_result; + END IF; + + RAISE NOTICE 'TEST PASSED: ssrf_block_localhost'; +END $$; + +DROP TABLE _test_ssrf2; + +-- ============================================================================ +-- Test 3: Block unsupported URL scheme (file://) +-- ============================================================================ + +CREATE TEMP TABLE _test_ssrf3 (instance_id TEXT); + +INSERT INTO _test_ssrf3 SELECT df.start( + df.http('file:///etc/passwd', 'GET'), + 'test-ssrf-file-scheme' +); + +DO $$ +DECLARE + inst_id TEXT; + status TEXT; + node_result TEXT; +BEGIN + SELECT instance_id INTO inst_id FROM _test_ssrf3; + RAISE NOTICE 'Testing SSRF block (file:// scheme): %', inst_id; + + SELECT df.wait_for_completion(inst_id) INTO status; + + IF status != 'failed' THEN + RAISE EXCEPTION 'TEST FAILED: file:// request should have failed, got status = %', status; + END IF; + + SELECT result::text INTO node_result + FROM df.nodes + WHERE instance_id = inst_id AND node_type = 'HTTP'; + + IF node_result IS NULL OR node_result NOT ILIKE '%unsupported URL scheme%' THEN + RAISE EXCEPTION 'TEST FAILED: expected "unsupported URL scheme" in error, got: %', node_result; + END IF; + + RAISE NOTICE 'TEST PASSED: ssrf_block_file_scheme'; +END $$; + +DROP TABLE _test_ssrf3; + +-- ============================================================================ +-- Test 4: Allow legitimate external HTTPS (sanity check) +-- ============================================================================ + +CREATE TEMP TABLE _test_ssrf4 (instance_id TEXT); + +INSERT INTO _test_ssrf4 SELECT df.start( + df.http('https://httpbingo.org/get', 'GET'), + 'test-ssrf-allow-public' +); + +DO $$ +DECLARE + inst_id TEXT; + status TEXT; +BEGIN + SELECT instance_id INTO inst_id FROM _test_ssrf4; + RAISE NOTICE 'Testing SSRF allows public HTTPS: %', inst_id; + + SELECT df.wait_for_completion(inst_id) INTO status; + + IF status != 'completed' THEN + RAISE EXCEPTION 'TEST FAILED: public HTTPS should succeed, got status = %', status; + END IF; + + RAISE NOTICE 'TEST PASSED: ssrf_allow_public'; +END $$; + +DROP TABLE _test_ssrf4; + +SELECT 'TEST PASSED' AS result;