From 9fde5f7dc501b648423ae697d194ae0f54064272 Mon Sep 17 00:00:00 2001 From: Pino de Candia <32303022+pinodeca@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:23:19 +0000 Subject: [PATCH 1/5] docs: add severity ratings and priority summary to security threat model Add impact/severity assessment to all threats (T0-T11) in the security spec. Severity reflects the unmitigated impact of each threat regardless of implementation status. - Add implementation priority summary table to Section 3.2 - Annotate each threat with severity and implementation status - Expand T4, T8, T9, T10, T11 descriptions with attack impact detail - Rewrite T8 (SSRF) to reference separate spec-ssrf-protection.md - Rewrite T9 to defer customer-level controls to a future spec --- docs/spec-security-model.md | 63 +++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/docs/spec-security-model.md b/docs/spec-security-model.md index d1f2f2d4..4fc74a9f 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** | Not 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,51 @@ 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**: Not implemented + +**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 — a malicious customer can probe/attack the hosting infrastructure's local network. ```sql --- Attack: Access AWS metadata endpoint +-- Attack: Access cloud metadata endpoint SELECT df.start( df.http('GET', 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'), 'ssrf-attack' ); ``` -**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**: Compile-time IP blocklist protecting the local network. See [spec-ssrf-protection.md](spec-ssrf-protection.md) for the full specification. -**Residual Risk**: Low — defense in depth (IP blocking + allowlist). +**Residual Risk**: Low once implemented — hardcoded blocklist cannot be bypassed by superusers. --- #### 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 +313,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 From c282017c7e34799ab409a08f7908ace6cfb618ff Mon Sep 17 00:00:00 2001 From: Pino de Candia <32303022+pinodeca@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:45:32 +0000 Subject: [PATCH 2/5] feat: add SSRF protection for df.http() Add compile-time dataplane protection that blocks HTTP requests to private/reserved IP ranges from the background worker. This prevents malicious users from using df.http() to probe internal network services, cloud metadata endpoints, or localhost services. Implementation: - New src/ssrf.rs module with IP blocklist (RFC 1918, loopback, link-local, ULA) and custom DNS resolver (SsrfSafeResolver) - Scheme validation: only http:// and https:// allowed - IPv4-mapped IPv6 handling (::ffff:A.B.C.D) - DNS rebinding protection via inline IP check before connect - Cargo feature 'ssrf-protection' (default=on), no runtime bypass - Audit logging: submitted_by and login_role on all HTTP requests - New spec: docs/spec-ssrf-protection.md Addresses threat T8 in spec-security-model.md. --- Cargo.toml | 3 +- docs/spec-ssrf-protection.md | 396 +++++++++++++++++++ src/activities/execute_http.rs | 44 ++- src/lib.rs | 1 + src/orchestrations/execute_function_graph.rs | 4 + src/ssrf.rs | 319 +++++++++++++++ src/types.rs | 6 + 7 files changed, 766 insertions(+), 7 deletions(-) create mode 100644 docs/spec-ssrf-protection.md create mode 100644 src/ssrf.rs diff --git a/Cargo.toml b/Cargo.toml index 3e9b21fc..23b9ae3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,8 @@ name = "pgrx_embed_pg_durable" path = "./src/bin/pgrx_embed.rs" [features] -default = ["pg17"] +default = ["pg17", "ssrf-protection"] +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-ssrf-protection.md b/docs/spec-ssrf-protection.md new file mode 100644 index 00000000..1ca92773 --- /dev/null +++ b/docs/spec-ssrf-protection.md @@ -0,0 +1,396 @@ +# Spec: SSRF Protection for `df.http()` + +**Status**: Draft +**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('GET', 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'), + 'steal-creds' +); +``` + +**Localhost service probing:** +```sql +SELECT df.start( + df.http('GET', 'http://127.0.0.1:8500/v1/agent/members'), + 'probe-consul' +); +``` + +**Internal network scanning:** +```sql +SELECT df.start( + df.http('GET', 'http://10.0.0.1:9090/api/v1/targets'), + 'probe-prometheus' +); +``` + +**IPv4-mapped IPv6 bypass:** +```sql +-- Same as 169.254.169.254 but via IPv6 notation +SELECT df.start( + df.http('GET', 'http://[::ffff:169.254.169.254]/latest/meta-data/'), + '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: ssrf-protection (default = on) │ +│ • 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 when the `ssrf-protection` feature is compiled in (which it is by default). 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", "ssrf-protection"] +ssrf-protection = [] +``` + +The `ssrf-protection` feature is **on by default**. When compiled without it (e.g., for local development or testing), all IP blocklist checks are skipped. + +The blocklist logic is gated with `#[cfg(feature = "ssrf-protection")]`: +- When enabled: IP blocklist is enforced, no override possible. +- When disabled: 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(feature = "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] == 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('GET', 'http://169.254.169.254/latest/meta-data/'), + 'test-ssrf-blocked' +); +-- Poll until failed, verify error contains "restricted range" +``` + +### 7.3 Feature Flag Test + +Verify that building without `ssrf-protection` allows all IPs (for development): + +```bash +cargo build --features pg17 --no-default-features +``` + +--- + +## 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..0978f106 100644 --- a/src/activities/execute_http.rs +++ b/src/activities/execute_http.rs @@ -1,4 +1,7 @@ //! ExecuteHTTP activity - makes HTTP requests +//! +//! SSRF protection: when the `ssrf-protection` feature is enabled (default), +//! requests to private/reserved IP ranges are blocked. See src/ssrf.rs. use duroxide::ActivityContext; use std::time::Duration; @@ -8,19 +11,48 @@ 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. +fn build_client(timeout: Duration) -> Result { + let builder = reqwest::Client::builder().timeout(timeout); + + #[cfg(feature = "ssrf-protection")] + 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 + )); + })?; + 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() { diff --git a/src/lib.rs b/src/lib.rs index c63b0766..b301ab83 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod explain; pub mod monitoring; pub mod orchestrations; pub mod registry; +pub mod ssrf; pub mod types; pub mod worker; diff --git a/src/orchestrations/execute_function_graph.rs b/src/orchestrations/execute_function_graph.rs index c8b4c701..61c0046e 100644 --- a/src/orchestrations/execute_function_graph.rs +++ b/src/orchestrations/execute_function_graph.rs @@ -756,6 +756,10 @@ async fn execute_http_node( config["headers"] = serde_json::Value::Object(new_headers); } + // Inject audit context from the function node + config["submitted_by"] = serde_json::Value::String(node.submitted_by.clone()); + config["login_role"] = serde_json::Value::String(node.login_role.clone()); + let final_config = config.to_string(); let url = config["url"].as_str().unwrap_or("?"); let method = config["method"].as_str().unwrap_or("POST"); diff --git a/src/ssrf.rs b/src/ssrf.rs new file mode 100644 index 00000000..798ac030 --- /dev/null +++ b/src/ssrf.rs @@ -0,0 +1,319 @@ +//! SSRF protection for df.http() — dataplane IP blocklist +//! +//! When the `ssrf-protection` Cargo feature is enabled (default), all HTTP +//! requests are validated against a blocklist of private/reserved IP ranges. +//! This prevents users from reaching internal network services, cloud metadata +//! endpoints, or localhost from within the PostgreSQL background worker. +//! +//! The blocklist is hardcoded and cannot be bypassed by any database user, +//! including superusers. See docs/spec-ssrf-protection.md for the full spec. + +#[cfg(feature = "ssrf-protection")] +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +/// Check if an IP address is in a blocked (private/reserved) range. +/// Returns `Some(reason)` if blocked, `None` if allowed. +#[cfg(feature = "ssrf-protection")] +pub fn check_blocked_ip(ip: IpAddr) -> 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), + } +} + +#[cfg(feature = "ssrf-protection")] +fn check_blocked_ipv4(ip: Ipv4Addr) -> Option<&'static str> { + 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, + } +} + +#[cfg(feature = "ssrf-protection")] +fn check_blocked_ipv6(ip: Ipv6Addr) -> Option<&'static str> { + 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." + )), + } +} + +// --------------------------------------------------------------------------- +// SSRF-safe DNS resolver — wraps the default resolver and filters out blocked IPs +// --------------------------------------------------------------------------- + +#[cfg(feature = "ssrf-protection")] +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) + }) + } + } +} + +#[cfg(feature = "ssrf-protection")] +pub use resolver::SsrfSafeResolver; + +// --------------------------------------------------------------------------- +// Default (system) DNS resolver — needed as the "inner" for SsrfSafeResolver +// --------------------------------------------------------------------------- + +#[cfg(feature = "ssrf-protection")] +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) + }) + } + } +} + +#[cfg(feature = "ssrf-protection")] +pub use system_resolver::SystemResolver; + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(all(test, feature = "ssrf-protection"))] +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()); + } +} 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 { From 3c7120bd3ef622194f1f846ab581025dbb21cd12 Mon Sep 17 00:00:00 2001 From: Pino de Candia <32303022+pinodeca@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:52:26 +0000 Subject: [PATCH 3/5] fix: block IP-literal SSRF bypass, add E2E tests and audit logging The initial SSRF implementation only checked IPs via a custom DNS resolver. However, reqwest does not invoke the DNS resolver for IP literal URLs (e.g., http://169.254.169.254/), leaving them unprotected. Fixes: - Add validate_url_host() pre-flight check that parses the URL, detects IP literals (including bracketed IPv6 like [::ffff:127.0.0.1]), and checks them against the blocklist before reqwest sees the URL - Add structured 'HTTP BLOCKED (ip)' audit log with submitted_by and login_role for both IP-literal and resolver-based blocks Tests: - E2E test (36_ssrf_protection.sql) with 4 cases: block metadata endpoint, block localhost, block file:// scheme, allow public HTTPS - Unit tests for validate_url_host() covering IPv4/IPv6 literals, public IPs, and hostname passthrough CI: - Split clippy into two steps: with and without ssrf-protection feature to verify both compilation modes --- .github/workflows/ci.yml | 5 +- src/activities/execute_http.rs | 21 ++++ src/ssrf.rs | 90 ++++++++++++++++ tests/e2e/sql/36_ssrf_protection.sql | 153 +++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/sql/36_ssrf_protection.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b1194b1..5db28d21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,9 +152,12 @@ jobs: https://${{ secrets.GH_PAT }}@github.com/microsoft/duroxide-pg-opt.git ./scripts/verify-duroxide-migrations.sh - - name: Run clippy + - name: Run clippy (without ssrf-protection) run: cargo clippy --no-default-features --features pg${{ matrix.pg_version }} -- -D warnings + - name: Run clippy (with ssrf-protection) + run: cargo clippy --no-default-features --features pg${{ matrix.pg_version }},ssrf-protection -- -D warnings + - name: Run unit tests run: cargo pgrx test pg${{ matrix.pg_version }} diff --git a/src/activities/execute_http.rs b/src/activities/execute_http.rs index 0978f106..adeac4b9 100644 --- a/src/activities/execute_http.rs +++ b/src/activities/execute_http.rs @@ -45,6 +45,15 @@ pub async fn execute(ctx: ActivityContext, config_json: String) -> Result Result Result<(), String> { } } +/// 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. +#[cfg(feature = "ssrf-protection")] +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 let Some(_reason) = check_blocked_ip(ip) { + 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(()) +} + +/// No-op when the feature is disabled. +#[cfg(not(feature = "ssrf-protection"))] +pub fn validate_url_host(_url: &str) -> Result<(), String> { + Ok(()) +} + // --------------------------------------------------------------------------- // SSRF-safe DNS resolver — wraps the default resolver and filters out blocked IPs // --------------------------------------------------------------------------- @@ -316,4 +372,38 @@ mod tests { 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/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; From 175da176d11a10627602e06518f2f7200e8a9294 Mon Sep 17 00:00:00 2001 From: Pino de Candia <32303022+pinodeca@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:06:36 +0000 Subject: [PATCH 4/5] refactor: invert SSRF feature flag to no-ssrf-protection, trim security spec - Invert Cargo feature: SSRF protection is now on by default (no feature needed). Add 'no-ssrf-protection' as an opt-in escape hatch for local development. This ensures --no-default-features builds still have SSRF protection enabled. - Update all cfg gates in ssrf.rs and execute_http.rs accordingly. - Update CI to clippy-check both configurations. - Trim duplicated SSRF implementation detail from spec-security-model.md, referencing spec-ssrf-protection.md for the full specification. - Update spec-ssrf-protection.md status to Completed. - Update spec-security-model.md T8 status to Implemented. --- .github/workflows/ci.yml | 6 +- Cargo.toml | 4 +- docs/spec-security-model.md | 131 +++------------------------------ docs/spec-ssrf-protection.md | 24 +++--- src/activities/execute_http.rs | 6 +- src/ssrf.rs | 33 +++++---- 6 files changed, 47 insertions(+), 157 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db28d21..768e15ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,11 +152,11 @@ jobs: https://${{ secrets.GH_PAT }}@github.com/microsoft/duroxide-pg-opt.git ./scripts/verify-duroxide-migrations.sh - - name: Run clippy (without ssrf-protection) + - name: Run clippy run: cargo clippy --no-default-features --features pg${{ matrix.pg_version }} -- -D warnings - - name: Run clippy (with ssrf-protection) - run: cargo clippy --no-default-features --features pg${{ matrix.pg_version }},ssrf-protection -- -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 23b9ae3e..ef01ea74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,8 @@ name = "pgrx_embed_pg_durable" path = "./src/bin/pgrx_embed.rs" [features] -default = ["pg17", "ssrf-protection"] -ssrf-protection = [] +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 4fc74a9f..de3f8a33 100644 --- a/docs/spec-security-model.md +++ b/docs/spec-security-model.md @@ -94,7 +94,7 @@ The security guarantee is: **only superusers can install the extension**, theref | Threat | Severity | Status | Notes | |--------|----------|--------|-------| -| **T8**: SSRF via HTTP Activity | **CRITICAL** | Not implemented | Dataplane protection — see [spec-ssrf-protection.md](spec-ssrf-protection.md) | +| **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 | @@ -249,21 +249,15 @@ ALTER TABLE df.nodes ENABLE ROW LEVEL SECURITY; #### T8: Server-Side Request Forgery (SSRF) via HTTP Activity -**Severity**: CRITICAL | **Status**: Not implemented +**Severity**: CRITICAL | **Status**: Implemented -**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 — a malicious customer can probe/attack the hosting infrastructure's local network. +**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. -```sql --- Attack: Access cloud metadata endpoint -SELECT df.start( - df.http('GET', 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'), - 'ssrf-attack' -); -``` +**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. -**Mitigation**: Compile-time IP blocklist protecting the local network. See [spec-ssrf-protection.md](spec-ssrf-protection.md) for the full specification. +See [spec-ssrf-protection.md](spec-ssrf-protection.md) for the full specification, blocked IP ranges, and implementation details. -**Residual Risk**: Low once implemented — hardcoded blocklist cannot be bypassed by superusers. +**Residual Risk**: Low — hardcoded blocklist cannot be bypassed. --- @@ -555,120 +549,13 @@ ALTER SYSTEM SET df.http_max_response_bytes = 10485760; -- 10MB ### 6.4 SSRF Protection -**Always-on protections** (cannot be disabled): +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. -| 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 | - -**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 index 1ca92773..d898400d 100644 --- a/docs/spec-ssrf-protection.md +++ b/docs/spec-ssrf-protection.md @@ -1,6 +1,6 @@ # Spec: SSRF Protection for `df.http()` -**Status**: Draft +**Status**: Completed **Threat**: T8 in [spec-security-model.md](spec-security-model.md) **Severity**: CRITICAL @@ -97,7 +97,7 @@ A successful SSRF attack from within the PG dataplane can: │ Layer 1: Dataplane Protection │ │ (this spec) │ │ │ -│ • Cargo feature: ssrf-protection (default = on) │ +│ • 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 │ @@ -114,7 +114,7 @@ A successful SSRF attack from within the PG dataplane can: └──────────────────────────────────────────────────────────┘ ``` -Layer 1 runs **inside** the HTTP activity, before the request is sent. It is always active when the `ssrf-protection` feature is compiled in (which it is by default). There is no GUC, no table, no superuser override. +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. @@ -190,15 +190,15 @@ A DNS rebinding attack works by returning a public IP on first lookup (passing t ```toml [features] -default = ["pg17", "ssrf-protection"] -ssrf-protection = [] +default = ["pg17"] +no-ssrf-protection = [] ``` -The `ssrf-protection` feature is **on by default**. When compiled without it (e.g., for local development or testing), all IP blocklist checks are skipped. +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), all IP blocklist checks are skipped. -The blocklist logic is gated with `#[cfg(feature = "ssrf-protection")]`: -- When enabled: IP blocklist is enforced, no override possible. -- When disabled: all URLs are allowed (development/testing only). +The blocklist logic is gated with `#[cfg(not(feature = "no-ssrf-protection"))]`: +- Default (feature absent): IP blocklist is enforced, no override possible. +- With `no-ssrf-protection` enabled: all URLs are allowed (development/testing only). ### 5.6 Error Messages @@ -234,7 +234,7 @@ 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(feature = "ssrf-protection")] +#[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 { @@ -373,10 +373,10 @@ SELECT df.start( ### 7.3 Feature Flag Test -Verify that building without `ssrf-protection` allows all IPs (for development): +Verify that building with `no-ssrf-protection` allows all IPs (for development): ```bash -cargo build --features pg17 --no-default-features +cargo build --features pg17,no-ssrf-protection ``` --- diff --git a/src/activities/execute_http.rs b/src/activities/execute_http.rs index adeac4b9..8cc49b98 100644 --- a/src/activities/execute_http.rs +++ b/src/activities/execute_http.rs @@ -1,7 +1,7 @@ //! ExecuteHTTP activity - makes HTTP requests //! -//! SSRF protection: when the `ssrf-protection` feature is enabled (default), -//! requests to private/reserved IP ranges are blocked. See src/ssrf.rs. +//! 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; @@ -15,7 +15,7 @@ pub const NAME: &str = "pg_durable::activity::execute-http"; fn build_client(timeout: Duration) -> Result { let builder = reqwest::Client::builder().timeout(timeout); - #[cfg(feature = "ssrf-protection")] + #[cfg(not(feature = "no-ssrf-protection"))] let builder = { use crate::ssrf::{SsrfSafeResolver, SystemResolver}; use std::sync::Arc; diff --git a/src/ssrf.rs b/src/ssrf.rs index 7f692b6c..7d59a5dd 100644 --- a/src/ssrf.rs +++ b/src/ssrf.rs @@ -1,19 +1,22 @@ //! SSRF protection for df.http() — dataplane IP blocklist //! -//! When the `ssrf-protection` Cargo feature is enabled (default), all HTTP -//! requests are validated against a blocklist of private/reserved IP ranges. -//! This prevents users from reaching internal network services, cloud metadata -//! endpoints, or localhost from within the PostgreSQL background worker. +//! SSRF protection is **enabled by default**. To disable it (e.g. for local +//! development), compile with the `no-ssrf-protection` Cargo feature. +//! +//! When active, all HTTP requests are validated against a blocklist of +//! private/reserved IP ranges, preventing users from reaching internal network +//! services, cloud metadata endpoints, or localhost from within the PostgreSQL +//! background worker. //! //! The blocklist is hardcoded and cannot be bypassed by any database user, //! including superusers. See docs/spec-ssrf-protection.md for the full spec. -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; /// Check if an IP address is in a blocked (private/reserved) range. /// Returns `Some(reason)` if blocked, `None` if allowed. -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] pub fn check_blocked_ip(ip: IpAddr) -> Option<&'static str> { // Handle IPv4-mapped IPv6 (::ffff:A.B.C.D) — extract the embedded IPv4 let ip = match ip { @@ -30,7 +33,7 @@ pub fn check_blocked_ip(ip: IpAddr) -> Option<&'static str> { } } -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] fn check_blocked_ipv4(ip: Ipv4Addr) -> Option<&'static str> { let octets = ip.octets(); match octets { @@ -44,7 +47,7 @@ fn check_blocked_ipv4(ip: Ipv4Addr) -> Option<&'static str> { } } -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] fn check_blocked_ipv6(ip: Ipv6Addr) -> Option<&'static str> { if ip.is_unspecified() { return Some("unspecified (::)"); @@ -83,7 +86,7 @@ pub fn validate_url_scheme(url: &str) -> Result<(), String> { /// /// 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. -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] pub fn validate_url_host(url: &str) -> Result<(), String> { // Strip scheme let after_scheme = match url.find("://") { @@ -127,7 +130,7 @@ pub fn validate_url_host(url: &str) -> Result<(), String> { } /// No-op when the feature is disabled. -#[cfg(not(feature = "ssrf-protection"))] +#[cfg(feature = "no-ssrf-protection")] pub fn validate_url_host(_url: &str) -> Result<(), String> { Ok(()) } @@ -136,7 +139,7 @@ pub fn validate_url_host(_url: &str) -> Result<(), String> { // SSRF-safe DNS resolver — wraps the default resolver and filters out blocked IPs // --------------------------------------------------------------------------- -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] mod resolver { use super::check_blocked_ip; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; @@ -177,14 +180,14 @@ mod resolver { } } -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] pub use resolver::SsrfSafeResolver; // --------------------------------------------------------------------------- // Default (system) DNS resolver — needed as the "inner" for SsrfSafeResolver // --------------------------------------------------------------------------- -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] mod system_resolver { use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use std::net::ToSocketAddrs; @@ -209,14 +212,14 @@ mod system_resolver { } } -#[cfg(feature = "ssrf-protection")] +#[cfg(not(feature = "no-ssrf-protection"))] pub use system_resolver::SystemResolver; // ============================================================================ // Tests // ============================================================================ -#[cfg(all(test, feature = "ssrf-protection"))] +#[cfg(all(test, not(feature = "no-ssrf-protection")))] mod tests { use super::*; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; From b340d4e17252d99a05c0c8b0ea206372383f0ea0 Mon Sep 17 00:00:00 2001 From: Pino de Candia <32303022+pinodeca@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:01:35 +0000 Subject: [PATCH 5/5] fix: address PR review comments for SSRF protection - Disable HTTP redirects to prevent redirect-based SSRF bypass - Fix df.http() argument order in spec examples (url, method) - Fix IPv6 link-local mask in spec code sample (& 0xffc0) - Replace fragile string matching with is_ssrf_block_error() helper - Simplify feature gate: only gate blocklist contents, not all code --- docs/spec-ssrf-protection.md | 18 +++---- src/activities/execute_http.rs | 12 +++-- src/ssrf.rs | 94 +++++++++++++++++++--------------- 3 files changed, 72 insertions(+), 52 deletions(-) diff --git a/docs/spec-ssrf-protection.md b/docs/spec-ssrf-protection.md index d898400d..583c569a 100644 --- a/docs/spec-ssrf-protection.md +++ b/docs/spec-ssrf-protection.md @@ -34,7 +34,7 @@ This spec defines the **dataplane protection layer** — a compile-time IP block **Cloud metadata exfiltration:** ```sql SELECT df.start( - df.http('GET', 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'), + df.http('http://169.254.169.254/latest/meta-data/iam/security-credentials/', 'GET'), 'steal-creds' ); ``` @@ -42,7 +42,7 @@ SELECT df.start( **Localhost service probing:** ```sql SELECT df.start( - df.http('GET', 'http://127.0.0.1:8500/v1/agent/members'), + df.http('http://127.0.0.1:8500/v1/agent/members', 'GET'), 'probe-consul' ); ``` @@ -50,7 +50,7 @@ SELECT df.start( **Internal network scanning:** ```sql SELECT df.start( - df.http('GET', 'http://10.0.0.1:9090/api/v1/targets'), + df.http('http://10.0.0.1:9090/api/v1/targets', 'GET'), 'probe-prometheus' ); ``` @@ -59,7 +59,7 @@ SELECT df.start( ```sql -- Same as 169.254.169.254 but via IPv6 notation SELECT df.start( - df.http('GET', 'http://[::ffff:169.254.169.254]/latest/meta-data/'), + df.http('http://[::ffff:169.254.169.254]/latest/meta-data/', 'GET'), 'ipv6-bypass' ); ``` @@ -194,11 +194,11 @@ 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), all IP blocklist checks are skipped. +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 logic is gated with `#[cfg(not(feature = "no-ssrf-protection"))]`: +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: all URLs are allowed (development/testing only). +- With `no-ssrf-protection` enabled: blocklist is empty, all URLs are allowed (development/testing only). ### 5.6 Error Messages @@ -272,7 +272,7 @@ fn check_blocked_ipv6(ip: Ipv6Addr) -> Option<&'static str> { return Some("loopback (::1)"); } let segments = ip.segments(); - if segments[0] == 0xfe80 { + if segments[0] & 0xffc0 == 0xfe80 { return Some("link-local (fe80::/10)"); } if segments[0] & 0xfe00 == 0xfc00 { @@ -365,7 +365,7 @@ Create a test that verifies blocked requests fail with the expected error messag ```sql -- Test: SSRF protection blocks link-local addresses SELECT df.start( - df.http('GET', 'http://169.254.169.254/latest/meta-data/'), + df.http('http://169.254.169.254/latest/meta-data/', 'GET'), 'test-ssrf-blocked' ); -- Poll until failed, verify error contains "restricted range" diff --git a/src/activities/execute_http.rs b/src/activities/execute_http.rs index 8cc49b98..db702a83 100644 --- a/src/activities/execute_http.rs +++ b/src/activities/execute_http.rs @@ -12,10 +12,16 @@ use crate::types::HttpConfig; 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); + let builder = reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()); - #[cfg(not(feature = "no-ssrf-protection"))] let builder = { use crate::ssrf::{SsrfSafeResolver, SystemResolver}; use std::sync::Arc; @@ -95,7 +101,7 @@ 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 { @@ -33,38 +33,52 @@ pub fn check_blocked_ip(ip: IpAddr) -> Option<&'static str> { } } -#[cfg(not(feature = "no-ssrf-protection"))] fn check_blocked_ipv4(ip: Ipv4Addr) -> Option<&'static str> { - 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, + #[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, + } } } -#[cfg(not(feature = "no-ssrf-protection"))] fn check_blocked_ipv6(ip: Ipv6Addr) -> Option<&'static str> { - 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)"); + #[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 } - None } /// Validate a URL scheme. Only `http` and `https` are permitted. @@ -86,7 +100,6 @@ pub fn validate_url_scheme(url: &str) -> Result<(), String> { /// /// 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. -#[cfg(not(feature = "no-ssrf-protection"))] pub fn validate_url_host(url: &str) -> Result<(), String> { // Strip scheme let after_scheme = match url.find("://") { @@ -117,8 +130,8 @@ pub fn validate_url_host(url: &str) -> Result<(), String> { // 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 let Some(_reason) = check_blocked_ip(ip) { + 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.", @@ -129,17 +142,21 @@ pub fn validate_url_host(url: &str) -> Result<(), String> { Ok(()) } -/// No-op when the feature is disabled. -#[cfg(feature = "no-ssrf-protection")] -pub fn validate_url_host(_url: &str) -> Result<(), String> { - 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 // --------------------------------------------------------------------------- -#[cfg(not(feature = "no-ssrf-protection"))] mod resolver { use super::check_blocked_ip; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; @@ -180,14 +197,12 @@ mod resolver { } } -#[cfg(not(feature = "no-ssrf-protection"))] pub use resolver::SsrfSafeResolver; // --------------------------------------------------------------------------- // Default (system) DNS resolver — needed as the "inner" for SsrfSafeResolver // --------------------------------------------------------------------------- -#[cfg(not(feature = "no-ssrf-protection"))] mod system_resolver { use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use std::net::ToSocketAddrs; @@ -212,14 +227,13 @@ mod system_resolver { } } -#[cfg(not(feature = "no-ssrf-protection"))] pub use system_resolver::SystemResolver; // ============================================================================ // Tests // ============================================================================ -#[cfg(all(test, not(feature = "no-ssrf-protection")))] +#[cfg(test)] mod tests { use super::*; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};