diff --git a/Cargo.lock b/Cargo.lock index e09c2583a..84e1315a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3054,6 +3054,7 @@ dependencies = [ name = "openshell-core" version = "0.0.0" dependencies = [ + "ipnet", "miette", "prost", "prost-types", @@ -3191,6 +3192,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "ipnet", "k8s-openapi", "kube", "kube-runtime", diff --git a/architecture/policy-advisor.md b/architecture/policy-advisor.md index 19edb0019..6d2728332 100644 --- a/architecture/policy-advisor.md +++ b/architecture/policy-advisor.md @@ -53,16 +53,24 @@ The `mechanistic_mapper` module (`crates/openshell-sandbox/src/mechanistic_mappe 1. Groups denial summaries by `(host, port, binary)` — one proposal per unique triple 2. For each group, generates a `NetworkPolicyRule` allowing that endpoint for that binary 3. Generates idempotent rule names via `generate_rule_name(host, port)` producing deterministic names like `allow_httpbin_org_443` — DB-level dedup handles uniqueness, no collision checking needed -4. Resolves each host via DNS; if any resolved IP is private (RFC 1918, loopback, link-local), populates `allowed_ips` in the proposed endpoint for the SSRF override -5. Computes confidence scores based on: +4. **Filters always-blocked destinations.** Before DNS resolution, `is_always_blocked_destination(host)` checks if the host is a literal always-blocked IP (loopback, link-local, unspecified) or the hostname `localhost`. If so, the proposal is skipped with an info log and `continue`. This prevents an infinite TUI notification loop: the proxy denies these destinations regardless of policy, so they re-trigger denials every flush cycle without any possible fix. The helper lives in the mapper module and delegates to `openshell_core::net::is_always_blocked_ip` for literal IP addresses. +5. Resolves each host via DNS; if any resolved IP is private (RFC 1918, loopback, link-local), populates `allowed_ips` in the proposed endpoint for the SSRF override. The `resolve_allowed_ips_if_private` function filters out always-blocked IPs from the resolved address list before populating `allowed_ips` — only RFC 1918/ULA addresses survive. If *all* resolved IPs are always-blocked (e.g., a host that resolves solely to `127.0.0.1`), the function returns an empty vec. +6. Computes confidence scores based on: - Denial count (higher count = higher confidence) - Port recognition (well-known ports like 443, 5432 get a boost) - SSRF origin (SSRF denials get lower confidence) -6. Generates security notes for private IPs, database ports, and ephemeral port ranges -7. If L7 request samples are present, generates specific L7 rules (method + path) with `protocol: rest` (TLS termination is automatic — no `tls` field needed). Plumbed but not yet fed data — see issue #205. +7. Generates security notes for private IPs, database ports, and ephemeral port ranges +8. If L7 request samples are present, generates specific L7 rules (method + path) with `protocol: rest` (TLS termination is automatic — no `tls` field needed). Plumbed but not yet fed data — see issue #205. The mapper runs in `flush_proposals_to_gateway` after the aggregator drains. It produces `PolicyChunk` protos that are sent alongside the raw `DenialSummary` protos to the gateway. +#### Shared IP Classification Helpers + +IP classification functions (`is_always_blocked_ip`, `is_always_blocked_net`, `is_internal_ip`) live in `openshell_core::net` (`crates/openshell-core/src/net.rs`). They are shared across the sandbox proxy (runtime SSRF enforcement), the mechanistic mapper (proposal filtering), and the gateway server (defense-in-depth validation on approval). The distinction between the two tiers: + +- **Always-blocked** (`is_always_blocked_ip`): loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`, `fe80::/10`), unspecified (`0.0.0.0`, `::`), and IPv4-mapped IPv6 equivalents. These are blocked unconditionally — no policy can override them. +- **Internal** (`is_internal_ip`): a superset that adds RFC 1918 (`10/8`, `172.16/12`, `192.168/16`) and IPv6 ULA (`fc00::/7`). These are blocked by default but can be allowed via `allowed_ips` in policy rules. + ### Gateway: Validate and Persist The gateway's `SubmitPolicyAnalysis` handler (`crates/openshell-server/src/grpc.rs`) is deliberately thin: @@ -75,6 +83,16 @@ The gateway's `SubmitPolicyAnalysis` handler (`crates/openshell-server/src/grpc. The gateway does not store denial summaries (they are included in the request for future audit trail use but not persisted today). It does not run the mapper or any analysis. +#### Always-Blocked Validation on Approval + +`merge_chunk_into_policy` (`crates/openshell-server/src/grpc/policy.rs`) validates proposed rules before merging them into the active policy. The `validate_rule_not_always_blocked` function runs as a defense-in-depth gate, catching rules that the sandbox mapper should have filtered but didn't (e.g., proposals from an older sandbox version): + +- Rejects endpoint hosts that parse as always-blocked IPs (loopback, link-local, unspecified) +- Rejects the literal hostname `localhost` (case-insensitive, with or without trailing dot) +- Rejects `allowed_ips` entries that parse as always-blocked networks via `is_always_blocked_net` + +On failure, the function returns `Status::invalid_argument` with a message explaining that the proxy will deny traffic to the destination regardless of policy. This uses the same `openshell_core::net` helpers as the sandbox-side filtering. + ### Persistence Draft chunks are stored in the gateway database: @@ -201,6 +219,20 @@ Keybindings are state-aware: | `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS` | `10` | How often the aggregator flushes and submits proposals | | `OPENSHELL_POLICY_POLL_INTERVAL_SECS` | `10` | How often the sandbox polls for policy updates | +## Known Behavior + +### Always-Blocked Destinations + +Destinations classified as always-blocked (loopback, link-local, unspecified, `localhost`) are filtered at three layers: + +1. **Sandbox mapper** — `generate_proposals` skips them before building a `PolicyChunk` +2. **Sandbox mapper** — `resolve_allowed_ips_if_private` strips always-blocked IPs from `allowed_ips`, returning empty if none survive +3. **Gateway approval** — `merge_chunk_into_policy` rejects them with `INVALID_ARGUMENT` + +If a sandbox process repeatedly attempts connections to these addresses, the proxy denies them every time and the denial aggregator accumulates counts. The mapper discards these summaries silently rather than forwarding un-fixable proposals. Before issue #814, these proposals would reach the TUI and reappear every flush cycle (default 10 seconds) since approving them would have no effect — the proxy blocks them regardless of policy. + +Existing `pending` rows for always-blocked destinations that were persisted before this filtering was added remain in the database. They are inert: attempting to approve them now fails at the gateway's `validate_rule_not_always_blocked` check. They can be rejected manually via `openshell rule reject` or left to age out. + ## Future Work (Issue #205) The LLM PolicyAdvisor agent will run sandbox-side via `inference.local`: diff --git a/architecture/sandbox.md b/architecture/sandbox.md index c5e212f85..656f42138 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -803,6 +803,8 @@ Every CONNECT request to a non-`inference.local` target produces an `info!()` lo After OPA allows a connection, the proxy resolves DNS and rejects any host that resolves to an internal IP address (loopback, RFC 1918 private, link-local, or IPv4-mapped IPv6 equivalents). This defense-in-depth measure prevents SSRF attacks where an allowed hostname is pointed at internal infrastructure. The check is implemented by `resolve_and_reject_internal()` which calls `tokio::net::lookup_host()` and validates every resolved address via `is_internal_ip()`. If any resolved IP is internal, the connection receives a `403 Forbidden` response and a warning is logged. See [SSRF Protection](security-policy.md#ssrf-protection-internal-ip-rejection) for the full list of blocked ranges. +IP classification helpers (`is_always_blocked_ip`, `is_always_blocked_net`, `is_internal_ip`) are shared from `openshell_core::net`. The `parse_allowed_ips` function rejects entries overlapping always-blocked ranges (loopback, link-local, unspecified) at load time with a hard error, and `implicit_allowed_ips_for_ip_host` skips synthesis for always-blocked literal IP hosts. The mechanistic mapper filters proposals for always-blocked destinations to prevent infinite TUI notification loops. + ### Inference interception When a CONNECT target is `inference.local`, the proxy TLS-terminates the client side and inspects the HTTP traffic to detect inference API calls. Matched requests are executed locally via the `openshell-router` crate. The function `handle_inference_interception()` implements this path and returns an `InferenceOutcome`: diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 01eb96f94..62c48b716 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -443,7 +443,7 @@ Each endpoint defines a network destination and, optionally, L7 inspection behav | `enforcement` | `string` | `"audit"` | L7 enforcement mode: `"enforce"` or `"audit"` | | `access` | `string` | `""` | Shorthand preset for common L7 rule sets. Mutually exclusive with `rules`. | | `rules` | `L7Rule[]` | `[]` | Explicit L7 allow rules. Mutually exclusive with `access`. | -| `allowed_ips` | `string[]` | `[]` | IP allowlist for SSRF override. See [Private IP Access via `allowed_ips`](#private-ip-access-via-allowed_ips). | +| `allowed_ips` | `string[]` | `[]` | IP allowlist for SSRF override. Entries overlapping always-blocked ranges (loopback, link-local, unspecified) are rejected at load time. See [Private IP Access via `allowed_ips`](#private-ip-access-via-allowed_ips). | #### `NetworkBinary` @@ -993,6 +993,7 @@ The following validation rules are enforced during policy loading (both file mod | `rules: []` (empty list) | `rules list cannot be empty (would deny all traffic). Use access: full or remove rules.` | | Host wildcard is bare `*` or `**` | `host wildcard '*' matches all hosts; use specific patterns like '*.example.com'` | | Host wildcard does not start with `*.` or `**.`| `host wildcard must start with '*.' or '**.' (e.g., '*.example.com'), got '{host}'` | +| `allowed_ips` entry overlaps always-blocked range | `allowed_ips entry {entry} falls within always-blocked range (loopback/link-local/unspecified)` | | Invalid HTTP method in REST rules | _(warning, not error)_ | ### Errors (Live Update Rejection) @@ -1005,6 +1006,16 @@ These errors are returned by the gateway's `UpdateSandboxPolicy` handler and rej | `landlock` differs from version 1 | `landlock policy cannot be changed on a live sandbox (applied at startup)` | | `process` differs from version 1 | `process policy cannot be changed on a live sandbox (applied at startup)` | +### Errors (Rule Merge Rejection) + +These errors are returned by the gateway's `merge_chunk_into_policy` when approving proposed rules. See `crates/openshell-server/src/grpc/policy.rs` -- `validate_rule_not_always_blocked()`. + +| Condition | Error Message | +|-----------|---------------| +| Proposed endpoint host is a literal always-blocked IP | `proposed rule endpoint host '{host}' is an always-blocked address (loopback/link-local/unspecified)` | +| Proposed endpoint host is `localhost` | `proposed rule endpoint host 'localhost' is always blocked` | +| Proposed `allowed_ips` entry overlaps always-blocked range | `proposed rule contains always-blocked allowed_ips entry '{entry}'` | + ### Warnings (Log Only) | Condition | Warning Message | @@ -1032,10 +1043,14 @@ These IP ranges are **always blocked**, even when `allowed_ips` is configured on |-------|-------------|--------| | `127.0.0.0/8` | IPv4 loopback | Prevents proxy bypass via localhost | | `169.254.0.0/16` | IPv4 link-local | Prevents cloud metadata SSRF (`169.254.169.254`) | +| `0.0.0.0` | IPv4 unspecified | Prevents binding/connecting to all interfaces | | `::1` | IPv6 loopback | Prevents proxy bypass via IPv6 localhost | +| `::` | IPv6 unspecified | Prevents binding/connecting to all interfaces | | `fe80::/10` | IPv6 link-local | Prevents IPv6 link-local access | | `::ffff:0:0/96` (mapped) | IPv4-mapped IPv6 addresses are unwrapped and checked as IPv4 | | +These ranges are enforced at multiple layers: load-time validation rejects `allowed_ips` entries that overlap these ranges (see [`parse_allowed_ips`](#implementation)), the server rejects proposed rules targeting them (see [Server-Side Defense-in-Depth](#server-side-defense-in-depth)), and the proxy runtime blocks resolved IPs that fall within them. + ### Default-Blocked IP Ranges (Private) These ranges are blocked by default but can be selectively allowed via the `allowed_ips` field on an endpoint: @@ -1049,17 +1064,32 @@ These ranges are blocked by default but can be selectively allowed via the `allo ### Implementation -Functions in `crates/openshell-sandbox/src/proxy.rs` implement the SSRF checks: +IP classification helpers live in `crates/openshell-core/src/net.rs` and are shared across the sandbox proxy, the mechanistic mapper, and the gateway server: + +- **`is_always_blocked_ip(ip: IpAddr) -> bool`**: Checks if an IP is always blocked regardless of policy — loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), and unspecified (`0.0.0.0`). For IPv6, unwraps IPv4-mapped addresses (`::ffff:x.x.x.x`) via `to_ipv4_mapped()` and applies IPv4 checks. Used in the `allowed_ips` code path and by `implicit_allowed_ips_for_ip_host` to enforce the hard block even when private IPs are permitted. + +- **`is_always_blocked_net(net: IpNet) -> bool`**: Checks if a CIDR network overlaps any always-blocked range. Returns `true` if the network contains or overlaps loopback, link-local, or unspecified addresses. A CIDR like `0.0.0.0/0` is rejected because it contains always-blocked addresses. Used at policy load time by `parse_allowed_ips` and at server-side approval time by `validate_rule_not_always_blocked`. -- **`is_internal_ip(ip: IpAddr) -> bool`**: Classifies an IP address as internal or public. Checks loopback, link-local, and RFC 1918 ranges. For IPv6, unwraps IPv4-mapped addresses (`::ffff:x.x.x.x`) via `to_ipv4_mapped()` and applies IPv4 checks. Used in the default (no `allowed_ips`) code path. +- **`is_internal_ip(ip: IpAddr) -> bool`**: Classifies an IP address as internal or public. Broader than `is_always_blocked_ip` — also includes RFC 1918 private ranges (`10/8`, `172.16/12`, `192.168/16`) and IPv6 ULA (`fc00::/7`). Used in the default (no `allowed_ips`) SSRF code path and by the mechanistic mapper to detect when `allowed_ips` should be populated in proposals. -- **`is_always_blocked_ip(ip: IpAddr) -> bool`**: Checks if an IP is always blocked regardless of policy — loopback and link-local only. Used in the `allowed_ips` code path to enforce the hard block on loopback and link-local even when private IPs are permitted. +Runtime resolution and enforcement functions remain in `crates/openshell-sandbox/src/proxy.rs`: - **`resolve_and_reject_internal(host, port) -> Result, String>`**: Default SSRF check. Resolves DNS via `tokio::net::lookup_host()`, then checks every resolved address against `is_internal_ip()`. If any address is internal, the entire connection is rejected. - **`resolve_and_check_allowed_ips(host, port, allowed_ips) -> Result, String>`**: Allowlist-based SSRF check. Resolves DNS, rejects any always-blocked IPs, then verifies every resolved address matches at least one entry in the `allowed_ips` list. -- **`parse_allowed_ips(raw) -> Result, String>`**: Parses CIDR/IP strings into typed `IpNet` values. Rejects entries that cover loopback or link-local ranges. Accepts both CIDR notation (`10.0.5.0/24`) and bare IPs (`10.0.5.20`, treated as `/32`). +- **`parse_allowed_ips(raw) -> Result, String>`**: Parses CIDR/IP strings into typed `IpNet` values. **Rejects entries at load time** that overlap always-blocked ranges (loopback, link-local, unspecified) via `is_always_blocked_net`. Accepts both CIDR notation (`10.0.5.0/24`) and bare IPs (`10.0.5.20`, treated as `/32`). This prevents confusing UX where an entry is accepted in policy but silently denied at runtime. + +- **`implicit_allowed_ips_for_ip_host(host) -> Vec`**: When a policy endpoint has a literal IP address as its host (e.g., `10.0.5.20`), synthesizes an `allowed_ips` entry so the allowlist-validation path is used instead of blanket internal-IP rejection. **Skips always-blocked addresses** — if the host is loopback, link-local, or unspecified, returns empty and logs a warning instead of synthesizing an un-enforceable entry. + +### Server-Side Defense-in-Depth + +The gateway server provides an additional validation layer when merging proposed rules into a sandbox's active policy. Before `merge_chunk_into_policy` applies a proposed rule, it calls `validate_rule_not_always_blocked` (in `crates/openshell-server/src/grpc/policy.rs`) which: + +1. Checks if the proposed endpoint host is a literal always-blocked IP (via `is_always_blocked_ip`) or `localhost`. +2. Checks each `allowed_ips` entry for overlap with always-blocked ranges (via `is_always_blocked_net`). + +If either check fails, the merge returns `INVALID_ARGUMENT` and the proposed rule is not applied. This prevents always-blocked destinations from entering the active policy even if the sandbox's mechanistic mapper or an older sandbox version did not filter them. ### Placement in Proxy Flow @@ -1075,8 +1105,11 @@ flowchart TD D --> E{Allowed?} E -- No --> F["403 Forbidden"] E -- Yes --> G{allowed_ips on endpoint?} - G -- Yes --> H["resolve_and_check_allowed_ips(host, port, nets)"] - H --> I{All IPs in allowlist
and not loopback/link-local?} + G -- Yes --> VAL["parse_allowed_ips:
validate no always-blocked entries"] + VAL --> VAL_OK{Valid?} + VAL_OK -- No --> J2["Connection rejected
(always-blocked entry in allowed_ips)"] + VAL_OK -- Yes --> H["resolve_and_check_allowed_ips(host, port, nets)"] + H --> I{All IPs in allowlist
and not always-blocked?} I -- No --> J["403 Forbidden + log warning"] I -- Yes --> K["TcpStream::connect(resolved addrs)"] G -- No --> L["resolve_and_reject_internal(host, port)"] @@ -1086,7 +1119,8 @@ flowchart TD K --> N["200 Connection Established"] FP --> FP_OPA["OPA evaluation + require allowed_ips"] - FP_OPA --> FP_RESOLVE["resolve_and_check_allowed_ips"] + FP_OPA --> FP_VAL["parse_allowed_ips: validate"] + FP_VAL --> FP_RESOLVE["resolve_and_check_allowed_ips"] FP_RESOLVE --> FP_PRIVATE{All IPs private?} FP_PRIVATE -- No --> J FP_PRIVATE -- Yes --> FP_CONNECT["TCP connect + rewrite + relay"] @@ -1094,7 +1128,11 @@ flowchart TD ### Private IP Access via `allowed_ips` -The `allowed_ips` field on a `NetworkEndpoint` enables controlled access to private IP space. When present, the default SSRF internal-IP rejection is replaced by an allowlist check: resolved IPs must match at least one entry in `allowed_ips`, and loopback/link-local are still always blocked. +The `allowed_ips` field on a `NetworkEndpoint` enables controlled access to private IP space. When present, the default SSRF internal-IP rejection is replaced by an allowlist check: resolved IPs must match at least one entry in `allowed_ips`, and always-blocked ranges (loopback, link-local, unspecified) are still rejected. + +**Load-time validation**: `parse_allowed_ips` rejects entries that overlap always-blocked ranges with a hard error at policy load time. This catches misconfigurations early — an entry like `127.0.0.0/8` or `0.0.0.0/0` in `allowed_ips` would be silently un-enforceable at runtime, so it is rejected before the policy is applied. The same validation runs in both file mode (sandbox startup) and gRPC mode (live policy updates via `OpaEngine::reload_from_proto`). + +**Implicit `allowed_ips` for IP hosts**: When a policy endpoint has a literal IP address as its host (e.g., `host: 10.0.5.20`), the proxy synthesizes an `allowed_ips` entry automatically via `implicit_allowed_ips_for_ip_host`. If the host is an always-blocked address (e.g., `127.0.0.1`, `169.254.169.254`, `0.0.0.0`), the function returns empty and logs a warning — no `allowed_ips` entry is synthesized, so the standard SSRF rejection applies. This supports three usage modes: @@ -1110,7 +1148,7 @@ Entries can be: - **CIDR notation**: `10.0.5.0/24`, `172.16.0.0/12`, `192.168.1.0/24` - **Exact IP**: `10.0.5.20` (treated as `/32` for IPv4 or `/128` for IPv6) -Entries that cover loopback (`127.0.0.0/8`) or link-local (`169.254.0.0/16`) ranges are rejected at parse time. +Entries that overlap always-blocked ranges — loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), or unspecified (`0.0.0.0`) — are rejected at load time with a hard error. Broad CIDRs that contain always-blocked addresses (e.g., `0.0.0.0/0`) are also rejected. #### Hostless Endpoints (`allowed_ips` without `host`) diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 8bccef54a..b03fb1494 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -19,6 +19,7 @@ miette = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } url = { workspace = true } +ipnet = "2" [features] ## Include test-only settings (dummy_bool, dummy_int) in the registry. diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 5cb10f395..30fb205ff 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod error; pub mod forward; pub mod image; pub mod inference; +pub mod net; pub mod paths; pub mod proto; pub mod settings; diff --git a/crates/openshell-core/src/net.rs b/crates/openshell-core/src/net.rs new file mode 100644 index 000000000..1d6f55dfb --- /dev/null +++ b/crates/openshell-core/src/net.rs @@ -0,0 +1,361 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Network IP classification utilities shared across OpenShell crates. +//! +//! These helpers enforce the always-blocked IP invariant (loopback, link-local, +//! unspecified) and the broader internal-IP classification (adds RFC 1918 and +//! ULA). They are used by: +//! - The sandbox proxy for runtime SSRF enforcement +//! - The mechanistic mapper for proposal filtering +//! - The gateway server for defense-in-depth validation on approval + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +/// Check if an IP address is always blocked regardless of policy. +/// +/// Loopback, link-local, and unspecified addresses are never allowed even when +/// an endpoint has `allowed_ips` configured. This prevents proxy bypass +/// (loopback) and cloud metadata SSRF (link-local 169.254.x.x). +pub fn is_always_blocked_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_loopback() || v4.is_link_local() || v4.is_unspecified(), + IpAddr::V6(v6) => { + if v6.is_loopback() || v6.is_unspecified() { + return true; + } + // fe80::/10 — IPv6 link-local + if (v6.segments()[0] & 0xffc0) == 0xfe80 { + return true; + } + // Check IPv4-mapped IPv6 (::ffff:x.x.x.x) + if let Some(v4) = v6.to_ipv4_mapped() { + return v4.is_loopback() || v4.is_link_local() || v4.is_unspecified(); + } + false + } + } +} + +/// Check if a CIDR network overlaps any always-blocked range. +/// +/// Returns `true` if the network contains or overlaps loopback (`127.0.0.0/8`), +/// link-local (`169.254.0.0/16`), unspecified (`0.0.0.0`), or their IPv6 +/// equivalents. A CIDR like `0.0.0.0/0` is rejected because it contains +/// always-blocked addresses. +/// +/// Used at policy load time and server-side approval to reject entries that +/// would be silently blocked at runtime by [`is_always_blocked_ip`]. +pub fn is_always_blocked_net(net: ipnet::IpNet) -> bool { + match net { + ipnet::IpNet::V4(v4net) => { + let network = v4net.network(); + let broadcast = v4net.broadcast(); + + // Check if the range overlaps 127.0.0.0/8 (loopback) + if broadcast >= Ipv4Addr::new(127, 0, 0, 0) + && network <= Ipv4Addr::new(127, 255, 255, 255) + { + return true; + } + + // Check if the range overlaps 169.254.0.0/16 (link-local) + if broadcast >= Ipv4Addr::new(169, 254, 0, 0) + && network <= Ipv4Addr::new(169, 254, 255, 255) + { + return true; + } + + // Check if the range contains 0.0.0.0 (unspecified) + if network == Ipv4Addr::UNSPECIFIED { + return true; + } + + false + } + ipnet::IpNet::V6(v6net) => { + // For IPv6, check the network address itself and representative + // addresses within the range. + let network = v6net.network(); + + // ::1 (loopback) + if v6net.contains(&Ipv6Addr::LOCALHOST) { + return true; + } + + // :: (unspecified) + if v6net.contains(&Ipv6Addr::UNSPECIFIED) { + return true; + } + + // fe80::/10 (link-local) — check overlap + if (network.segments()[0] & 0xffc0) == 0xfe80 { + return true; + } + // Also check if a broad prefix contains fe80:: + if v6net.contains(&Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0)) { + return true; + } + + // Check IPv4-mapped IPv6 (::ffff:127.0.0.1, ::ffff:169.254.x.x, etc.) + if let Some(v4) = network.to_ipv4_mapped() { + if v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() { + return true; + } + } + + false + } + } +} + +/// Check if an IP address is internal (loopback, private RFC 1918, link-local, +/// or unspecified). +/// +/// This is a broader check than [`is_always_blocked_ip`] — it includes RFC 1918 +/// private ranges (`10/8`, `172.16/12`, `192.168/16`) and IPv6 ULA (`fc00::/7`) +/// which are allowable via `allowed_ips` but blocked by default without one. +/// +/// Used by the proxy's default SSRF path and the mechanistic mapper to detect +/// when `allowed_ips` should be populated in proposals. +pub fn is_internal_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() + } + IpAddr::V6(v6) => { + if v6.is_loopback() || v6.is_unspecified() { + return true; + } + // fe80::/10 — IPv6 link-local + if (v6.segments()[0] & 0xffc0) == 0xfe80 { + return true; + } + // fc00::/7 — IPv6 unique local addresses (ULA) + if (v6.segments()[0] & 0xfe00) == 0xfc00 { + return true; + } + // Check IPv4-mapped IPv6 (::ffff:x.x.x.x) + if let Some(v4) = v6.to_ipv4_mapped() { + return v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified(); + } + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- is_always_blocked_ip -- + + #[test] + fn test_always_blocked_ip_loopback_v4() { + assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( + 127, 0, 0, 2 + )))); + } + + #[test] + fn test_always_blocked_ip_link_local_v4() { + assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( + 169, 254, 169, 254 + )))); + assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( + 169, 254, 0, 1 + )))); + } + + #[test] + fn test_always_blocked_ip_loopback_v6() { + assert!(is_always_blocked_ip(IpAddr::V6(Ipv6Addr::LOCALHOST))); + } + + #[test] + fn test_always_blocked_ip_link_local_v6() { + assert!(is_always_blocked_ip(IpAddr::V6(Ipv6Addr::new( + 0xfe80, 0, 0, 0, 0, 0, 0, 1 + )))); + } + + #[test] + fn test_always_blocked_ip_unspecified_v4() { + assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED))); + } + + #[test] + fn test_always_blocked_ip_unspecified_v6() { + assert!(is_always_blocked_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED))); + } + + #[test] + fn test_always_blocked_ip_ipv4_mapped_v6_loopback() { + let v6 = Ipv4Addr::LOCALHOST.to_ipv6_mapped(); + assert!(is_always_blocked_ip(IpAddr::V6(v6))); + } + + #[test] + fn test_always_blocked_ip_ipv4_mapped_v6_link_local() { + let v6 = Ipv4Addr::new(169, 254, 169, 254).to_ipv6_mapped(); + assert!(is_always_blocked_ip(IpAddr::V6(v6))); + } + + #[test] + fn test_always_blocked_ip_allows_rfc1918() { + assert!(!is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( + 10, 0, 0, 1 + )))); + assert!(!is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( + 172, 16, 0, 1 + )))); + assert!(!is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( + 192, 168, 0, 1 + )))); + } + + #[test] + fn test_always_blocked_ip_allows_public() { + assert!(!is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); + assert!(!is_always_blocked_ip(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888 + )))); + } + + // -- is_always_blocked_net -- + + #[test] + fn test_always_blocked_net_loopback_v4() { + let net: ipnet::IpNet = "127.0.0.0/8".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_link_local_v4() { + let net: ipnet::IpNet = "169.254.0.0/16".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_unspecified_v4() { + let net: ipnet::IpNet = "0.0.0.0/32".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_loopback_v6() { + let net: ipnet::IpNet = "::1/128".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_link_local_v6() { + let net: ipnet::IpNet = "fe80::/10".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_ipv4_mapped_v6_loopback() { + let net: ipnet::IpNet = "::ffff:127.0.0.1/128".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_allows_rfc1918() { + let net10: ipnet::IpNet = "10.0.0.0/8".parse().unwrap(); + let net172: ipnet::IpNet = "172.16.0.0/12".parse().unwrap(); + let net192: ipnet::IpNet = "192.168.0.0/16".parse().unwrap(); + assert!(!is_always_blocked_net(net10)); + assert!(!is_always_blocked_net(net172)); + assert!(!is_always_blocked_net(net192)); + } + + #[test] + fn test_always_blocked_net_allows_public() { + let net: ipnet::IpNet = "8.8.8.0/24".parse().unwrap(); + assert!(!is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_single_ip_loopback() { + let net: ipnet::IpNet = "127.0.0.1/32".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_single_ip_metadata() { + let net: ipnet::IpNet = "169.254.169.254/32".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_broad_cidr_containing_blocked() { + // 0.0.0.0/0 contains everything including unspecified, loopback, link-local + let net: ipnet::IpNet = "0.0.0.0/0".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + #[test] + fn test_always_blocked_net_v6_broad_containing_loopback() { + let net: ipnet::IpNet = "::/0".parse().unwrap(); + assert!(is_always_blocked_net(net)); + } + + // -- is_internal_ip -- + + #[test] + fn test_internal_ip_rfc1918() { + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))); + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)))); + } + + #[test] + fn test_internal_ip_loopback() { + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::LOCALHOST))); + } + + #[test] + fn test_internal_ip_link_local() { + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new( + 169, 254, 169, 254 + )))); + } + + #[test] + fn test_internal_ip_unspecified() { + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED))); + assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED))); + } + + #[test] + fn test_internal_ip_v6_ula() { + assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::new( + 0xfc00, 0, 0, 0, 0, 0, 0, 1 + )))); + assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::new( + 0xfd00, 0, 0, 0, 0, 0, 0, 1 + )))); + } + + #[test] + fn test_internal_ip_allows_public() { + assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); + assert!(!is_internal_ip(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888 + )))); + } + + #[test] + fn test_internal_ip_ipv4_mapped_v6() { + let v6 = Ipv4Addr::new(10, 0, 0, 1).to_ipv6_mapped(); + assert!(is_internal_ip(IpAddr::V6(v6))); + let v6_public = Ipv4Addr::new(8, 8, 8, 8).to_ipv6_mapped(); + assert!(!is_internal_ip(IpAddr::V6(v6_public))); + } +} diff --git a/crates/openshell-ocsf/src/builders/http.rs b/crates/openshell-ocsf/src/builders/http.rs index 9cc49c9e8..96919f281 100644 --- a/crates/openshell-ocsf/src/builders/http.rs +++ b/crates/openshell-ocsf/src/builders/http.rs @@ -24,6 +24,7 @@ pub struct HttpActivityBuilder<'a> { actor: Option, firewall_rule: Option, message: Option, + status_detail: Option, } impl<'a> HttpActivityBuilder<'a> { @@ -43,6 +44,7 @@ impl<'a> HttpActivityBuilder<'a> { actor: None, firewall_rule: None, message: None, + status_detail: None, } } @@ -106,6 +108,11 @@ impl<'a> HttpActivityBuilder<'a> { self.message = Some(msg.into()); self } + #[must_use] + pub fn status_detail(mut self, detail: impl Into) -> Self { + self.status_detail = Some(detail.into()); + self + } #[must_use] pub fn build(self) -> OcsfEvent { @@ -127,6 +134,9 @@ impl<'a> HttpActivityBuilder<'a> { if let Some(msg) = self.message { base.set_message(msg); } + if let Some(detail) = self.status_detail { + base.set_status_detail(detail); + } base.set_device(self.ctx.device()); base.set_container(self.ctx.container()); @@ -175,4 +185,27 @@ mod tests { assert_eq!(json["http_request"]["http_method"], "GET"); assert_eq!(json["actor"]["process"]["name"], "curl"); } + + #[test] + fn test_http_activity_builder_with_status_detail() { + let ctx = test_sandbox_context(); + let event = HttpActivityBuilder::new(&ctx) + .activity(ActivityId::Other) + .action(ActionId::Denied) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + "PUT", + Url::new("http", "169.254.169.254", "/latest/api/token", 80), + )) + .firewall_rule("aws_iam", "ssrf") + .message("FORWARD blocked: allowed_ips check failed") + .status_detail("resolves to always-blocked address") + .build(); + + let json = event.to_json().unwrap(); + assert_eq!(json["class_uid"], 4002); + assert_eq!(json["status_detail"], "resolves to always-blocked address"); + assert_eq!(json["action_id"], 2); // Denied + } } diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 3b245e10e..bd5f50c8d 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -6,6 +6,7 @@ //! Pattern: ` [context]` use crate::events::OcsfEvent; +use crate::events::base_event::BaseEventData; use crate::objects::Url; /// Format a timestamp (ms since epoch) as `HH:MM:SS.mmm`. @@ -59,6 +60,27 @@ pub fn severity_tag(severity_id: u8) -> &'static str { } } +/// Max length for the reason text in `[reason:...]` before truncation. +const MAX_REASON_LEN: usize = 80; + +/// Format a `[reason:...]` tag from `status_detail` (or `message` fallback) +/// for denied events. Returns an empty string if neither field is set. +fn reason_tag(base: &BaseEventData) -> String { + let text = base + .status_detail + .as_deref() + .or(base.message.as_deref()) + .unwrap_or(""); + if text.is_empty() { + return String::new(); + } + if text.len() > MAX_REASON_LEN { + format!(" [reason:{}...]", &text[..MAX_REASON_LEN]) + } else { + format!(" [reason:{text}]") + } +} + impl OcsfEvent { /// Produce the single-line shorthand for `openshell.log` and gRPC log push. /// @@ -97,6 +119,12 @@ impl OcsfEvent { .as_ref() .map(|r| format!(" [policy:{} engine:{}]", r.name, r.rule_type)) .unwrap_or_default(); + // For denied events, surface the reason from status_detail + let reason_ctx = if action == "DENIED" { + reason_tag(&e.base) + } else { + String::new() + }; let arrow = if actor_str.is_empty() && dst.is_empty() { String::new() } else if actor_str.is_empty() { @@ -113,7 +141,7 @@ impl OcsfEvent { (false, true) => format!(" {action}"), (false, false) => format!(" {action}{arrow}"), }; - format!("NET:{activity} {sev}{detail}{rule_ctx}") + format!("NET:{activity} {sev}{detail}{rule_ctx}{reason_ctx}") } Self::HttpActivity(e) => { @@ -136,8 +164,14 @@ impl OcsfEvent { let rule_ctx = e .firewall_rule .as_ref() - .map(|r| format!(" [policy:{}]", r.name)) + .map(|r| format!(" [policy:{} engine:{}]", r.name, r.rule_type)) .unwrap_or_default(); + // For denied events, surface the reason from status_detail + let reason_ctx = if action == "DENIED" { + reason_tag(&e.base) + } else { + String::new() + }; let arrow = if actor_str.is_empty() { format!(" {method} {url_str}") } else { @@ -150,7 +184,7 @@ impl OcsfEvent { (false, true) => format!(" {action}"), (false, false) => format!(" {action}{arrow}"), }; - format!("HTTP:{method} {sev}{detail}{rule_ctx}") + format!("HTTP:{method} {sev}{detail}{rule_ctx}{reason_ctx}") } Self::SshActivity(e) => { @@ -443,7 +477,142 @@ mod tests { let shorthand = event.format_shorthand(); assert_eq!( shorthand, - "HTTP:GET [INFO] ALLOWED curl(88) -> GET https://api.example.com/v1/data [policy:default-egress]" + "HTTP:GET [INFO] ALLOWED curl(88) -> GET https://api.example.com/v1/data [policy:default-egress engine:mechanistic]" + ); + } + + #[test] + fn test_network_activity_shorthand_denied_shows_reason() { + let mut b = base(4001, "Network Activity", 4, "Network Activity", 1, "Open"); + b.severity = crate::enums::SeverityId::Medium; + b.set_status_detail( + "169.254.169.254 resolves to always-blocked address 169.254.169.254, connection rejected" + .to_string(), + ); + + let event = OcsfEvent::NetworkActivity(NetworkActivityEvent { + base: b, + src_endpoint: None, + dst_endpoint: Some(Endpoint::from_domain("169.254.169.254", 80)), + proxy_endpoint: None, + actor: Some(Actor { + process: Process::new("curl", 1618), + }), + firewall_rule: Some(FirewallRule::new("-", "ssrf")), + connection_info: None, + action: Some(ActionId::Denied), + disposition: Some(DispositionId::Blocked), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let shorthand = event.format_shorthand(); + assert!( + shorthand.contains("[reason:"), + "denied shorthand should contain [reason:]: {shorthand}" + ); + assert!( + shorthand.contains("always-blocked"), + "reason should contain 'always-blocked': {shorthand}" + ); + } + + #[test] + fn test_network_activity_shorthand_allowed_no_reason() { + let event = OcsfEvent::NetworkActivity(NetworkActivityEvent { + base: base(4001, "Network Activity", 4, "Network Activity", 1, "Open"), + src_endpoint: None, + dst_endpoint: Some(Endpoint::from_domain("api.example.com", 443)), + proxy_endpoint: None, + actor: Some(Actor { + process: Process::new("python3", 42), + }), + firewall_rule: Some(FirewallRule::new("default-egress", "mechanistic")), + connection_info: None, + action: Some(ActionId::Allowed), + disposition: Some(DispositionId::Allowed), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let shorthand = event.format_shorthand(); + assert!( + !shorthand.contains("[reason:"), + "allowed shorthand should NOT contain [reason:]: {shorthand}" + ); + } + + #[test] + fn test_http_activity_shorthand_denied_shows_reason() { + let mut b = base(4002, "HTTP Activity", 4, "Network Activity", 99, "Other"); + b.severity = crate::enums::SeverityId::Medium; + b.set_status_detail("not in allowed_ips".to_string()); + + let event = OcsfEvent::HttpActivity(HttpActivityEvent { + base: b, + http_request: Some(HttpRequest::new( + "PUT", + Url::new("http", "169.254.169.254", "/latest/api/token", 80), + )), + http_response: None, + src_endpoint: None, + dst_endpoint: None, + proxy_endpoint: None, + actor: Some(Actor { + process: Process::new("curl", 1618), + }), + firewall_rule: Some(FirewallRule::new("aws_iam", "ssrf")), + action: Some(ActionId::Denied), + disposition: Some(DispositionId::Blocked), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let shorthand = event.format_shorthand(); + assert!( + shorthand.contains("[reason:not in allowed_ips]"), + "denied HTTP shorthand should contain [reason:not in allowed_ips]: {shorthand}" + ); + assert!( + shorthand.contains("[policy:aws_iam engine:ssrf]"), + "denied HTTP shorthand should contain engine: {shorthand}" + ); + } + + #[test] + fn test_shorthand_reason_truncated_at_80_chars() { + let long_reason = "a".repeat(120); + let mut b = base(4001, "Network Activity", 4, "Network Activity", 1, "Open"); + b.severity = crate::enums::SeverityId::Medium; + b.set_status_detail(long_reason.clone()); + + let event = OcsfEvent::NetworkActivity(NetworkActivityEvent { + base: b, + src_endpoint: None, + dst_endpoint: Some(Endpoint::from_domain("example.com", 443)), + proxy_endpoint: None, + actor: None, + firewall_rule: None, + connection_info: None, + action: Some(ActionId::Denied), + disposition: Some(DispositionId::Blocked), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let shorthand = event.format_shorthand(); + assert!( + shorthand.contains("[reason:"), + "should have reason tag: {shorthand}" + ); + assert!( + shorthand.contains("...]"), + "long reason should be truncated with ...: {shorthand}" + ); + // The full 120-char reason should not appear + assert!( + !shorthand.contains(&long_reason), + "full reason should not appear: {shorthand}" ); } diff --git a/crates/openshell-sandbox/src/mechanistic_mapper.rs b/crates/openshell-sandbox/src/mechanistic_mapper.rs index 95800854a..7ca7538ab 100644 --- a/crates/openshell-sandbox/src/mechanistic_mapper.rs +++ b/crates/openshell-sandbox/src/mechanistic_mapper.rs @@ -12,6 +12,7 @@ //! The LLM-powered `PolicyAdvisor` (issue #205) wraps and enriches these //! mechanistic proposals with context-aware rationale and smarter grouping. +use openshell_core::net::{is_always_blocked_ip, is_internal_ip}; use openshell_core::proto::{ DenialSummary, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, }; @@ -101,6 +102,20 @@ pub async fn generate_proposals(summaries: &[DenialSummary]) -> Vec } } + // Skip proposals for always-blocked destinations (loopback, + // link-local, unspecified). These would be denied at runtime by the + // proxy's is_always_blocked_ip check regardless of policy, producing + // an infinite proposal loop in the TUI. + if is_always_blocked_destination(host) { + tracing::info!( + host, + port, + "Skipped proposal for always-blocked destination \ + (SSRF hardening — loopback/link-local/unspecified)" + ); + continue; + } + // Resolve the host and check if any IP is private. When a host // resolves to private IP space, the proxy requires `allowed_ips` as an // explicit SSRF override. Public IPs don't need this. @@ -406,33 +421,19 @@ fn short_binary_name(path: &str) -> String { path.rsplit('/').next().unwrap_or(path).to_string() } -/// Check if an IP address is in private/internal space. +/// Check if a destination host is always-blocked. /// -/// Matches the same ranges as the proxy's `is_internal_ip`: loopback, -/// RFC 1918 private, link-local (IPv4), plus loopback, link-local, and -/// ULA (IPv6). IPv4-mapped IPv6 addresses are unwrapped and checked. -fn is_internal_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), - IpAddr::V6(v6) => { - if v6.is_loopback() { - return true; - } - // fe80::/10 — IPv6 link-local - if (v6.segments()[0] & 0xffc0) == 0xfe80 { - return true; - } - // fc00::/7 — IPv6 unique local addresses (ULA) - if (v6.segments()[0] & 0xfe00) == 0xfc00 { - return true; - } - // Check IPv4-mapped IPv6 (::ffff:x.x.x.x) - if let Some(v4) = v6.to_ipv4_mapped() { - return v4.is_loopback() || v4.is_private() || v4.is_link_local(); - } - false - } - } +/// For literal IP hosts, checks against [`is_always_blocked_ip`]. +/// For hostnames like "localhost", checks well-known loopback names. +/// For other hostnames, returns false (DNS may resolve to anything). +fn is_always_blocked_destination(host: &str) -> bool { + // Check literal IP addresses + if let Ok(ip) = host.parse::() { + return is_always_blocked_ip(ip); + } + // Check well-known loopback hostnames + let host_lc = host.to_lowercase(); + host_lc == "localhost" || host_lc == "localhost." } /// Resolve a hostname and return the IPs as `allowed_ips` strings only if any @@ -478,10 +479,28 @@ async fn resolve_allowed_ips_if_private(host: &str, port: u32) -> Vec { return Vec::new(); } - // Host has private IPs — include all resolved IPs in allowed_ips. - let mut ips: Vec = addrs.iter().map(|a| a.ip().to_string()).collect(); + // Host has private IPs — include non-always-blocked resolved IPs in + // allowed_ips. Always-blocked addresses (loopback, link-local, + // unspecified) are filtered out since the proxy will reject them + // regardless of policy. + let mut ips: Vec = addrs + .iter() + .filter(|a| !is_always_blocked_ip(a.ip())) + .map(|a| a.ip().to_string()) + .collect(); ips.sort(); ips.dedup(); + + if ips.is_empty() { + // All resolved IPs were always-blocked — no viable allowed_ips. + tracing::debug!( + host, + port, + "All resolved IPs are always-blocked; skipping allowed_ips" + ); + return Vec::new(); + } + tracing::debug!( host, port, @@ -690,6 +709,119 @@ mod tests { )))); } + // -- is_always_blocked_destination tests ------------------------------------ + + #[test] + fn test_always_blocked_destination_loopback_ip() { + assert!(is_always_blocked_destination("127.0.0.1")); + } + + #[test] + fn test_always_blocked_destination_link_local_ip() { + assert!(is_always_blocked_destination("169.254.169.254")); + } + + #[test] + fn test_always_blocked_destination_unspecified_ip() { + assert!(is_always_blocked_destination("0.0.0.0")); + } + + #[test] + fn test_always_blocked_destination_localhost_hostname() { + assert!(is_always_blocked_destination("localhost")); + assert!(is_always_blocked_destination("LOCALHOST")); + } + + #[test] + fn test_always_blocked_destination_allows_rfc1918() { + assert!(!is_always_blocked_destination("10.0.5.20")); + assert!(!is_always_blocked_destination("192.168.1.1")); + } + + #[test] + fn test_always_blocked_destination_allows_public_hostname() { + assert!(!is_always_blocked_destination("api.github.com")); + } + + // -- generate_proposals: always-blocked filtering tests -------------------- + + #[tokio::test] + async fn test_generate_proposals_skips_loopback_destination() { + let summaries = vec![DenialSummary { + host: "127.0.0.1".to_string(), + port: 80, + binary: "/usr/bin/curl".to_string(), + count: 5, + first_seen_ms: 1000, + last_seen_ms: 2000, + denial_stage: "ssrf".to_string(), + ..Default::default() + }]; + + let proposals = generate_proposals(&summaries).await; + assert!( + proposals.is_empty(), + "should skip proposals for loopback: {proposals:?}" + ); + } + + #[tokio::test] + async fn test_generate_proposals_skips_link_local_destination() { + let summaries = vec![DenialSummary { + host: "169.254.169.254".to_string(), + port: 80, + binary: "/usr/bin/curl".to_string(), + count: 5, + first_seen_ms: 1000, + last_seen_ms: 2000, + denial_stage: "ssrf".to_string(), + ..Default::default() + }]; + + let proposals = generate_proposals(&summaries).await; + assert!( + proposals.is_empty(), + "should skip proposals for link-local: {proposals:?}" + ); + } + + #[tokio::test] + async fn test_generate_proposals_skips_localhost_hostname() { + let summaries = vec![DenialSummary { + host: "localhost".to_string(), + port: 8080, + binary: "/usr/bin/curl".to_string(), + count: 3, + first_seen_ms: 1000, + last_seen_ms: 2000, + denial_stage: "ssrf".to_string(), + ..Default::default() + }]; + + let proposals = generate_proposals(&summaries).await; + assert!( + proposals.is_empty(), + "should skip proposals for localhost: {proposals:?}" + ); + } + + #[tokio::test] + async fn test_generate_proposals_keeps_public_destination() { + let summaries = vec![DenialSummary { + host: "api.github.com".to_string(), + port: 443, + binary: "/usr/bin/curl".to_string(), + count: 5, + first_seen_ms: 1000, + last_seen_ms: 2000, + denial_stage: "connect".to_string(), + ..Default::default() + }]; + + let proposals = generate_proposals(&summaries).await; + assert_eq!(proposals.len(), 1, "should keep proposals for public host"); + } + #[test] fn test_generalise_path() { // Exact path preserved. diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index b52cc60b9..c055d9e6d 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -10,6 +10,7 @@ use crate::opa::{NetworkAction, OpaEngine}; use crate::policy::ProxyPolicy; use crate::secrets::{SecretResolver, rewrite_header_line}; use miette::{IntoDiagnostic, Result}; +use openshell_core::net::{is_always_blocked_ip, is_internal_ip}; use openshell_ocsf::{ ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, @@ -1442,49 +1443,25 @@ fn query_tls_mode( } } -/// Check if an IP address is internal (loopback, private RFC1918, link-local, or unspecified). -/// -/// This is a defense-in-depth check to prevent SSRF via the CONNECT proxy. -/// It covers: -/// - IPv4 loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (`0.0.0.0`) -/// - IPv6 loopback (`::1`), link-local (`fe80::/10`), ULA (`fc00::/7`), unspecified (`::`) -/// - IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) are unwrapped and checked as IPv4 -fn is_internal_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() - } - IpAddr::V6(v6) => { - if v6.is_loopback() || v6.is_unspecified() { - return true; - } - // fe80::/10 — IPv6 link-local - if (v6.segments()[0] & 0xffc0) == 0xfe80 { - return true; - } - // fc00::/7 — IPv6 unique local addresses (ULA) - if (v6.segments()[0] & 0xfe00) == 0xfc00 { - return true; - } - // Check IPv4-mapped IPv6 (::ffff:x.x.x.x) - if let Some(v4) = v6.to_ipv4_mapped() { - return v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified(); - } - false - } - } -} - /// When the policy endpoint host is a literal IP address, the user has /// explicitly declared intent to allow that destination. Synthesize an /// `allowed_ips` entry so the existing allowlist-validation path is used -/// instead of the blanket internal-IP rejection. Loopback and link-local -/// addresses are still blocked by `resolve_and_check_allowed_ips`. +/// instead of the blanket internal-IP rejection. +/// +/// Always-blocked addresses (loopback, link-local, unspecified) are skipped +/// — synthesizing an `allowed_ips` entry for them would be silently +/// un-enforceable at runtime. fn implicit_allowed_ips_for_ip_host(host: &str) -> Vec { - if host.parse::().is_ok() { + if let Ok(ip) = host.parse::() { + if is_always_blocked_ip(ip) { + warn!( + host, + "Policy host is an always-blocked address; \ + implicit allowed_ips skipped — SSRF hardening prevents \ + traffic to this destination regardless of policy" + ); + return vec![]; + } vec![host.to_string()] } else { vec![] @@ -1522,31 +1499,6 @@ async fn resolve_and_reject_internal( Ok(addrs) } -/// Check if an IP address is always blocked regardless of policy. -/// -/// Loopback, link-local, and unspecified addresses are never allowed even when an endpoint -/// has `allowed_ips` configured. This prevents proxy bypass (loopback) and -/// cloud metadata SSRF (link-local 169.254.x.x). -fn is_always_blocked_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => v4.is_loopback() || v4.is_link_local() || v4.is_unspecified(), - IpAddr::V6(v6) => { - if v6.is_loopback() || v6.is_unspecified() { - return true; - } - // fe80::/10 — IPv6 link-local - if (v6.segments()[0] & 0xffc0) == 0xfe80 { - return true; - } - // Check IPv4-mapped IPv6 (::ffff:x.x.x.x) - if let Some(v4) = v6.to_ipv4_mapped() { - return v4.is_loopback() || v4.is_link_local() || v4.is_unspecified(); - } - false - } - } -} - /// Resolve DNS and validate resolved addresses against a CIDR/IP allowlist. /// /// Rejects loopback and link-local unconditionally. For all other resolved @@ -1616,11 +1568,15 @@ const BLOCKED_CONTROL_PLANE_PORTS: &[u16] = &[ ]; /// Parse CIDR/IP strings into `IpNet` values, rejecting invalid entries and -/// entries that cover loopback or link-local ranges. +/// entries that overlap always-blocked ranges (loopback, link-local, +/// unspecified). /// /// Returns parsed networks on success, or an error describing which entries -/// are invalid. Logs a warning for overly broad CIDRs. +/// are invalid or always-blocked. Logs a warning for overly broad CIDRs +/// that are not outright blocked. fn parse_allowed_ips(raw: &[String]) -> std::result::Result, String> { + use openshell_core::net::is_always_blocked_net; + let mut nets = Vec::with_capacity(raw.len()); let mut errors = Vec::new(); @@ -1638,6 +1594,19 @@ fn parse_allowed_ips(raw: &[String]) -> std::result::Result, S match parsed { Ok(n) => { + // Reject entries that overlap always-blocked ranges — these + // would be silently denied at runtime by is_always_blocked_ip + // and cause confusing UX (accepted in policy, never works). + if is_always_blocked_net(n) { + errors.push(format!( + "allowed_ips entry {entry} falls within always-blocked range \ + (loopback/link-local/unspecified); remove this entry — \ + SSRF hardening prevents traffic to these destinations \ + regardless of policy" + )); + continue; + } + if n.prefix_len() < MIN_SAFE_PREFIX_LEN { let event = NetworkActivityBuilder::new(crate::ocsf_ctx()) .activity(ActivityId::Other) @@ -2240,8 +2209,9 @@ async fn handle_forward_proxy( ) .firewall_rule(policy_str, "ssrf") .message(format!( - "FORWARD blocked: allowed_ips check failed for {host_lc}:{port}: {reason}" + "FORWARD blocked: allowed_ips check failed for {host_lc}:{port}" )) + .status_detail(&reason) .build(); ocsf_emit!(event); } @@ -2278,8 +2248,9 @@ async fn handle_forward_proxy( ) .firewall_rule(policy_str, "ssrf") .message(format!( - "FORWARD blocked: invalid allowed_ips in policy for {host_lc}:{port}: {reason}" + "FORWARD blocked: invalid allowed_ips in policy for {host_lc}:{port}" )) + .status_detail(&reason) .build(); ocsf_emit!(event); } @@ -2320,8 +2291,9 @@ async fn handle_forward_proxy( ) .firewall_rule(policy_str, "ssrf") .message(format!( - "FORWARD blocked: internal IP without allowed_ips for {host_lc}:{port}: {reason}" + "FORWARD blocked: internal IP without allowed_ips for {host_lc}:{port}" )) + .status_detail(&reason) .build(); ocsf_emit!(event); } @@ -2895,7 +2867,8 @@ mod tests { #[tokio::test] async fn test_resolve_check_allowed_ips_blocks_loopback() { - let nets = parse_allowed_ips(&["127.0.0.0/8".to_string()]).unwrap(); + // Construct nets directly (parse_allowed_ips now rejects always-blocked). + let nets = vec!["127.0.0.0/8".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("127.0.0.1", 80, &nets).await; assert!(result.is_err()); let err = result.unwrap_err(); @@ -2907,7 +2880,8 @@ mod tests { #[tokio::test] async fn test_resolve_check_allowed_ips_blocks_metadata() { - let nets = parse_allowed_ips(&["169.254.0.0/16".to_string()]).unwrap(); + // Construct nets directly (parse_allowed_ips now rejects always-blocked). + let nets = vec!["169.254.0.0/16".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("169.254.169.254", 80, &nets).await; assert!(result.is_err()); let err = result.unwrap_err(); @@ -2919,7 +2893,8 @@ mod tests { #[tokio::test] async fn test_resolve_check_allowed_ips_blocks_unspecified() { - let nets = parse_allowed_ips(&["0.0.0.0/0".to_string()]).unwrap(); + // Construct nets directly (parse_allowed_ips now rejects always-blocked). + let nets = vec!["0.0.0.0/0".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("0.0.0.0", 80, &nets).await; assert!(result.is_err()); let err = result.unwrap_err(); @@ -2946,7 +2921,8 @@ mod tests { #[tokio::test] async fn test_resolve_check_allowed_ips_blocks_control_plane_ports() { - let nets = parse_allowed_ips(&["0.0.0.0/0".to_string()]).unwrap(); + // Use a public CIDR (parse_allowed_ips now rejects 0.0.0.0/0). + let nets = parse_allowed_ips(&["8.8.8.0/24".to_string()]).unwrap(); // K8s API server port let result = resolve_and_check_allowed_ips("8.8.8.8", 6443, &nets).await; assert!(result.is_err()); @@ -2978,6 +2954,90 @@ mod tests { assert!(result.is_ok()); } + // --- parse_allowed_ips: always-blocked rejection tests --- + + #[test] + fn test_parse_allowed_ips_rejects_loopback_cidr() { + let result = parse_allowed_ips(&["127.0.0.0/8".to_string()]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("always-blocked")); + } + + #[test] + fn test_parse_allowed_ips_rejects_link_local_cidr() { + let result = parse_allowed_ips(&["169.254.0.0/16".to_string()]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("always-blocked")); + } + + #[test] + fn test_parse_allowed_ips_rejects_unspecified() { + let result = parse_allowed_ips(&["0.0.0.0".to_string()]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("always-blocked")); + } + + #[test] + fn test_parse_allowed_ips_rejects_single_loopback_ip() { + let result = parse_allowed_ips(&["127.0.0.1".to_string()]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("always-blocked")); + } + + #[test] + fn test_parse_allowed_ips_rejects_single_metadata_ip() { + let result = parse_allowed_ips(&["169.254.169.254".to_string()]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("always-blocked")); + } + + #[test] + fn test_parse_allowed_ips_rejects_wildcard_cidr() { + let result = parse_allowed_ips(&["0.0.0.0/0".to_string()]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("always-blocked")); + } + + #[test] + fn test_parse_allowed_ips_mixed_valid_and_blocked() { + // A blocked entry taints the whole batch. + let result = parse_allowed_ips(&["10.0.5.0/24".to_string(), "127.0.0.1".to_string()]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("always-blocked")); + } + + #[test] + fn test_parse_allowed_ips_accepts_rfc1918() { + let result = parse_allowed_ips(&["10.0.5.0/24".to_string(), "192.168.1.0/24".to_string()]); + assert!(result.is_ok()); + } + + // --- implicit_allowed_ips_for_ip_host: always-blocked skip tests --- + + #[test] + fn test_implicit_allowed_ips_skips_loopback() { + let result = implicit_allowed_ips_for_ip_host("127.0.0.1"); + assert!(result.is_empty()); + } + + #[test] + fn test_implicit_allowed_ips_skips_link_local() { + let result = implicit_allowed_ips_for_ip_host("169.254.169.254"); + assert!(result.is_empty()); + } + + #[test] + fn test_implicit_allowed_ips_skips_unspecified() { + let result = implicit_allowed_ips_for_ip_host("0.0.0.0"); + assert!(result.is_empty()); + } + + #[test] + fn test_implicit_allowed_ips_allows_rfc1918() { + let result = implicit_allowed_ips_for_ip_host("10.0.5.20"); + assert_eq!(result, vec!["10.0.5.20"]); + } + // --- extract_host_from_uri tests --- #[test] @@ -3244,7 +3304,8 @@ mod tests { #[tokio::test] async fn test_forward_loopback_always_blocked_even_with_allowed_ips() { // Loopback addresses are always blocked, even if in allowed_ips. - let nets = parse_allowed_ips(&["127.0.0.0/8".to_string()]).unwrap(); + // Construct nets directly (parse_allowed_ips now rejects always-blocked). + let nets = vec!["127.0.0.0/8".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("127.0.0.1", 80, &nets).await; assert!(result.is_err(), "Loopback should be always blocked"); let err = result.unwrap_err(); @@ -3257,7 +3318,8 @@ mod tests { #[tokio::test] async fn test_forward_link_local_always_blocked_even_with_allowed_ips() { // Link-local / cloud metadata addresses are always blocked. - let nets = parse_allowed_ips(&["169.254.0.0/16".to_string()]).unwrap(); + // Construct nets directly (parse_allowed_ips now rejects always-blocked). + let nets = vec!["169.254.0.0/16".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("169.254.169.254", 80, &nets).await; assert!(result.is_err(), "Link-local should be always blocked"); let err = result.unwrap_err(); @@ -3276,9 +3338,10 @@ mod tests { } #[test] - fn test_implicit_allowed_ips_returns_ip_for_ipv6_literal() { + fn test_implicit_allowed_ips_skips_ipv6_loopback() { + // ::1 is always-blocked, so implicit allowed_ips should be empty. let result = implicit_allowed_ips_for_ip_host("::1"); - assert_eq!(result, vec!["::1"]); + assert!(result.is_empty()); } #[test] diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 0308f30ff..60a34fff2 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -73,6 +73,7 @@ hex = "0.4" russh = "0.57" rand = "0.9" petname = "2" +ipnet = "2" [features] dev-settings = ["openshell-core/dev-settings"] diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index a1639d1ce..bac76172f 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1610,6 +1610,62 @@ fn generate_security_notes(host: &str, port: u16) -> String { notes.join(" ") } +/// Reject proposed rules whose endpoints or `allowed_ips` target +/// always-blocked addresses (loopback, link-local, unspecified). +/// +/// This is defense-in-depth: the proxy blocks these at runtime, so +/// merging them into the active policy would be silently un-enforceable. +fn validate_rule_not_always_blocked( + rule: &openshell_core::proto::NetworkPolicyRule, +) -> Result<(), Status> { + use openshell_core::net::{is_always_blocked_ip, is_always_blocked_net}; + use std::net::IpAddr; + + for ep in &rule.endpoints { + // Check if the endpoint host is a literal always-blocked IP. + if let Ok(ip) = ep.host.parse::() { + if is_always_blocked_ip(ip) { + return Err(Status::invalid_argument(format!( + "proposed rule endpoint host '{}' is an always-blocked address \ + (loopback/link-local/unspecified); the proxy will deny traffic \ + to this destination regardless of policy", + ep.host + ))); + } + } + let host_lc = ep.host.to_lowercase(); + if host_lc == "localhost" || host_lc == "localhost." { + return Err(Status::invalid_argument( + "proposed rule endpoint host 'localhost' is always blocked; \ + the proxy will deny traffic to loopback regardless of policy" + .to_string(), + )); + } + + // Check allowed_ips entries. + for entry in &ep.allowed_ips { + let parsed = entry.parse::().or_else(|_| { + entry.parse::().map(|ip| match ip { + IpAddr::V4(v4) => ipnet::IpNet::V4(ipnet::Ipv4Net::from(v4)), + IpAddr::V6(v6) => ipnet::IpNet::V6(ipnet::Ipv6Net::from(v6)), + }) + }); + if let Ok(net) = parsed { + if is_always_blocked_net(net) { + return Err(Status::invalid_argument(format!( + "proposed rule contains always-blocked allowed_ips entry '{entry}'; \ + SSRF hardening prevents traffic to these destinations \ + regardless of policy" + ))); + } + } + // Invalid entries are not our concern here — the sandbox's + // parse_allowed_ips handles syntax validation. + } + } + Ok(()) +} + async fn require_no_global_policy(state: &ServerState) -> Result<(), Status> { let global = load_global_settings(state.store.as_ref()).await?; if global.settings.contains_key(POLICY_SETTING_KEY) { @@ -1631,6 +1687,11 @@ pub(super) async fn merge_chunk_into_policy( let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) .map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?; + // Defense-in-depth: reject proposed rules targeting always-blocked + // destinations. Even if the sandbox mapper didn't filter these (e.g., + // an older sandbox version), the proxy will deny them at runtime. + validate_rule_not_always_blocked(&rule)?; + for attempt in 1..=MERGE_RETRY_LIMIT { let latest = store .get_latest_policy(sandbox_id) @@ -2427,6 +2488,119 @@ mod tests { assert!(policy.network_policies.contains_key("allow_10_0_0_5_8080")); } + // ---- validate_rule_not_always_blocked ---- + + #[test] + fn validate_rule_rejects_loopback_allowed_ips() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + + let rule = NetworkPolicyRule { + name: "bad".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 80, + allowed_ips: vec!["127.0.0.1".to_string()], + ..Default::default() + }], + binaries: vec![], + }; + let result = validate_rule_not_always_blocked(&rule); + assert!(result.is_err()); + let status = result.unwrap_err(); + assert_eq!(status.code(), Code::InvalidArgument); + assert!(status.message().contains("always-blocked")); + } + + #[test] + fn validate_rule_rejects_link_local_allowed_ips() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + + let rule = NetworkPolicyRule { + name: "bad".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 80, + allowed_ips: vec!["169.254.169.254".to_string()], + ..Default::default() + }], + binaries: vec![], + }; + let result = validate_rule_not_always_blocked(&rule); + assert!(result.is_err()); + assert!(result.unwrap_err().message().contains("always-blocked")); + } + + #[test] + fn validate_rule_rejects_always_blocked_host() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + + let rule = NetworkPolicyRule { + name: "bad".to_string(), + endpoints: vec![NetworkEndpoint { + host: "127.0.0.1".to_string(), + port: 80, + ..Default::default() + }], + binaries: vec![], + }; + let result = validate_rule_not_always_blocked(&rule); + assert!(result.is_err()); + assert!(result.unwrap_err().message().contains("always-blocked")); + } + + #[test] + fn validate_rule_rejects_localhost_host() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + + let rule = NetworkPolicyRule { + name: "bad".to_string(), + endpoints: vec![NetworkEndpoint { + host: "localhost".to_string(), + port: 8080, + ..Default::default() + }], + binaries: vec![], + }; + let result = validate_rule_not_always_blocked(&rule); + assert!(result.is_err()); + assert!(result.unwrap_err().message().contains("always blocked")); + } + + #[test] + fn validate_rule_accepts_rfc1918_allowed_ips() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + + let rule = NetworkPolicyRule { + name: "good".to_string(), + endpoints: vec![NetworkEndpoint { + host: "internal.corp".to_string(), + port: 443, + allowed_ips: vec!["10.0.5.0/24".to_string()], + ..Default::default() + }], + binaries: vec![], + }; + let result = validate_rule_not_always_blocked(&rule); + assert!(result.is_ok()); + } + + #[test] + fn validate_rule_accepts_public_host() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + + let rule = NetworkPolicyRule { + name: "good".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![], + }; + let result = validate_rule_not_always_blocked(&rule); + assert!(result.is_ok()); + } + // ---- Settings tests ---- #[test] diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 4bc194d22..25adde360 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -34,8 +34,8 @@ In the log file, OCSF events appear in a shorthand format with an `OCSF` level l 2026-04-01T04:04:13.074Z INFO openshell_sandbox: Creating OPA engine from proto policy data 2026-04-01T04:04:13.078Z OCSF CONFIG:VALIDATED [INFO] Validated 'sandbox' user exists in image 2026-04-01T04:04:32.118Z OCSF NET:OPEN [INFO] ALLOWED /usr/bin/curl(58) -> api.github.com:443 [policy:github_api engine:opa] -2026-04-01T04:04:32.190Z OCSF HTTP:GET [INFO] ALLOWED GET http://api.github.com/zen [policy:github_api] -2026-04-01T04:04:32.690Z OCSF NET:OPEN [MED] DENIED /usr/bin/curl(64) -> httpbin.org:443 [policy:- engine:opa] +2026-04-01T04:04:32.190Z OCSF HTTP:GET [INFO] ALLOWED GET http://api.github.com/zen [policy:github_api engine:opa] +2026-04-01T04:04:32.690Z OCSF NET:OPEN [MED] DENIED /usr/bin/curl(64) -> httpbin.org:443 [policy:- engine:opa] [reason:no matching policy] ``` The `OCSF` label at column 25 distinguishes structured events from standard `INFO` tracing at the same position. Both formats appear in the same file. @@ -44,7 +44,7 @@ When viewed through the CLI or TUI, which receive logs via gRPC, the same distin ```text [1775014132.118] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] ALLOWED /usr/bin/curl(58) -> api.github.com:443 [policy:github_api engine:opa] -[1775014132.690] [sandbox] [OCSF ] [ocsf] NET:OPEN [MED] DENIED /usr/bin/curl(64) -> httpbin.org:443 [policy:- engine:opa] +[1775014132.690] [sandbox] [OCSF ] [ocsf] NET:OPEN [MED] DENIED /usr/bin/curl(64) -> httpbin.org:443 [policy:- engine:opa] [reason:no matching policy] [1775014113.058] [sandbox] [INFO ] [openshell_sandbox] Starting sandbox ``` @@ -109,13 +109,19 @@ OCSF NET:OPEN [INFO] ALLOWED /usr/bin/curl(58) -> api.github.com:443 [policy:git An L7 read-only policy denying a POST: ```text -OCSF HTTP:POST [MED] DENIED POST http://api.github.com/user/repos [policy:github_api] +OCSF HTTP:POST [MED] DENIED POST http://api.github.com/user/repos [policy:github_api engine:opa] ``` A connection denied because no policy matched: ```text -OCSF NET:OPEN [MED] DENIED /usr/bin/curl(64) -> httpbin.org:443 [policy:- engine:opa] +OCSF NET:OPEN [MED] DENIED /usr/bin/curl(64) -> httpbin.org:443 [policy:- engine:opa] [reason:no matching policy] +``` + +A connection denied because the destination resolves to an always-blocked address: + +```text +OCSF NET:OPEN [MED] DENIED /usr/bin/curl(1618) -> 169.254.169.254:80 [policy:- engine:ssrf] [reason:resolves to always-blocked address] ``` Proxy and SSH servers ready: diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 6612da6aa..27034a479 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -104,6 +104,7 @@ And a denied connection: "action": "Denied", "disposition_id": 2, "disposition": "Blocked", + "status_detail": "no matching policy", "message": "CONNECT denied httpbin.org:443", "dst_endpoint": { "domain": "httpbin.org", diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 4a5b3b384..e9795475d 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -159,6 +159,7 @@ Each endpoint defines a reachable destination and optional inspection rules. | `enforcement` | string | No | `enforce` actively blocks disallowed requests. `audit` logs violations but allows traffic through. | | `access` | string | No | HTTP access level. One of `read-only`, `read-write`, or `full`. Mutually exclusive with `rules`. | | `rules` | list of rule objects | No | Fine-grained per-method, per-path allow rules. Mutually exclusive with `access`. | +| `allowed_ips` | list of string | No | CIDR or IP allowlist for SSRF override. Entries overlapping loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), or unspecified (`0.0.0.0`) are rejected at load time. | #### Access Levels diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 77f138c18..80dd72059 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -117,10 +117,10 @@ After OPA policy allows a connection, the proxy resolves DNS and rejects connect | Aspect | Detail | |---|---| -| Default | The proxy blocks all private IPs. Loopback (`127.0.0.0/8`) and link-local (`169.254.0.0/16`) remain blocked even with `allowed_ips`. | -| What you can change | Add `allowed_ips` (CIDR notation) to an endpoint to permit connections to specific private IP ranges. | +| Default | The proxy blocks all private IPs. Loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), and unspecified (`0.0.0.0`) addresses are always blocked and cannot be overridden with `allowed_ips`. | +| What you can change | Add `allowed_ips` (CIDR notation) to an endpoint to permit connections to specific private IP ranges. Policies with `allowed_ips` entries that overlap loopback, link-local, or unspecified addresses fail to load with a clear validation error. | | Risk if relaxed | Without SSRF protection, a misconfigured policy could allow the agent to reach cloud metadata services (`169.254.169.254`), internal databases, or other infrastructure endpoints through DNS rebinding. | -| Recommendation | Use `allowed_ips` only for known internal services. Scope the CIDR as narrowly as possible (for example, `10.0.5.20/32` for a single host). Loopback and link-local are always blocked regardless of `allowed_ips`. | +| Recommendation | Use `allowed_ips` only for known internal services. Scope the CIDR as narrowly as possible (for example, `10.0.5.20/32` for a single host). Loopback, link-local, and unspecified addresses are always blocked regardless of `allowed_ips`. The policy advisor does not propose rules for always-blocked destinations. | ### Operator Approval diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index a56eb599a..b81c9fc78 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -694,8 +694,10 @@ def test_ssrf_log_shows_blocked_address( ) -> None: """SSRF-3: Proxy log includes block reason when SSRF check fires. - Loopback addresses are always-blocked even with implicit allowed_ips. - The log should show 'always-blocked' for 127.0.0.1. + Loopback addresses are always-blocked. Since implicit_allowed_ips_for_ip_host + now skips always-blocked hosts, 127.0.0.1 falls through to the default + resolve_and_reject_internal path which blocks it as an internal address. + The shorthand log should include 'ssrf' and a '[reason:' tag for denied events. """ policy = _base_policy( network_policies={ @@ -719,6 +721,10 @@ def test_ssrf_log_shows_blocked_address( assert "engine:ssrf" in log.lower() or "ssrf" in log.lower(), ( f"Expected SSRF block indicator in proxy log, got:\n{log}" ) + # Shorthand for denied events should include [reason:...] tag + assert "[reason:" in log.lower(), ( + f"Expected [reason:] tag in denied event shorthand, got:\n{log}" + ) # ============================================================================= @@ -839,7 +845,13 @@ def test_ssrf_private_ip_allowed_with_literal_ip_host( def test_ssrf_loopback_blocked_even_with_allowed_ips( sandbox: Callable[..., Sandbox], ) -> None: - """SSRF-7: Loopback always blocked even when allowed_ips covers 127.0.0.0/8.""" + """SSRF-7: Loopback always blocked even when allowed_ips covers 127.0.0.0/8. + + With always-blocked validation, parse_allowed_ips rejects 127.0.0.0/8 at + connection time (returns Err), so the proxy treats this as "invalid + allowed_ips in policy" and returns 403. The end result is the same: + loopback is never reachable. + """ policy = _base_policy( network_policies={ "internal": sandbox_pb2.NetworkPolicyRule(