Skip to content

feat: block outbound connections to private/custom IP ranges (SSRF protection)#1971

Open
lucoffe wants to merge 4 commits intolightpanda-io:mainfrom
lucoffe:feat/add-ip-filter
Open

feat: block outbound connections to private/custom IP ranges (SSRF protection)#1971
lucoffe wants to merge 4 commits intolightpanda-io:mainfrom
lucoffe:feat/add-ip-filter

Conversation

@lucoffe
Copy link
Copy Markdown
Contributor

@lucoffe lucoffe commented Mar 23, 2026

Motivation

When Lightpanda is used as a service (e.g. web scraping, rendering), user-supplied URLs can trigger requests to internal infrastructure such as cloud metadata endpoints, internal APIs, localhost services. This is a standard SSRF vector. These flags let operators block connections to internal IP ranges at the network level.

Usage

lightpanda fetch --block-private-networks https://example.com
lightpanda serve --block-cidrs 10.0.0.0/8,169.254.169.254/32,fd00:ec2::254/128
lightpanda fetch --block-private-networks --block-cidrs 203.0.113.0/24 https://example.com
lightpanda serve --block-private-networks --block-cidrs -10.0.0.42/32

--block-private-networks blocks RFC1918, localhost, link-local, and ULA ranges.
--block-cidrs blocks additional comma-separated CIDRs. Works standalone or combined.
Prefix a CIDR with - to allow (exempt from blocking), e.g. -10.0.0.42/32 exempts that IP even if it falls in a blocked range. Allow rules take precedence over all block rules.

How it works

sequenceDiagram
    participant Browser as Browser engine
    participant Curl as libcurl
    participant DNS as DNS resolver
    participant CB as opensocketCallback
    participant Filter as IpFilter
    participant Net as Network

    Browser->>Curl: HTTP request (any origin)
    Curl->>DNS: resolve hostname
    DNS-->>Curl: resolved IP (sockaddr)
    Curl->>CB: CURLOPT_OPENSOCKETFUNCTION(sockaddr)
    CB->>Filter: isBlockedSockaddr(sockaddr)
    alt IP matches blocked CIDR
        Filter-->>CB: blocked
        CB-->>Curl: CURL_SOCKET_BAD
        Curl-->>Browser: CURLE_COULDNT_CONNECT
    else IP allowed
        Filter-->>CB: allowed
        CB->>Net: posix.socket()
        Net-->>CB: fd
        CB-->>Curl: fd
        Curl->>Net: TCP SYN →
    end
Loading

The filter hooks into libcurl's CURLOPT_OPENSOCKETFUNCTION: after DNS resolution but before socket creation. It reads the resolved IP directly from the sockaddr struct (no string parsing) and does bitwise CIDR comparisons.

Evaluation order: allow list first (immediate pass), then private ranges, then custom block CIDRs. The filter is fail-closed: unknown address families and null pointers are blocked.

IPv4-mapped IPv6 addresses (::ffff:10.0.0.1) are unwrapped and checked against IPv4 rules to prevent bypass.

Why CURLOPT_OPENSOCKETFUNCTION over CDP Fetch.enable?

CDP Fetch.enable intercepts requests before DNS resolution:

  • URL-only: can't see resolved IPs, so a public hostname resolving to an internal IP slips through
  • Requires an active CDP session: not available for CLI fetch or headless serve without a client
  • WebSocket round-trip per request: JSON ser/de + allocations on every intercepted request

The opensocket callback operates after DNS:

  • IP-level: sees the actual resolved sockaddr, does bitwise CIDR comparison
  • Always active: CLI flags, no CDP session required
  • Inline in libcurl: no round-trip, no allocations

Changes

File Change
src/network/IpFilter.zig New. CIDR filter engine: comptime private range tables, runtime CIDR parsing with - prefix for allow-listing, bitwise IPv4/IPv6 matching, IPv4-mapped-v6 unwrapping.
src/network/http.zig opensocketCallback: checks filter, logs blocked IPs, returns CURL_SOCKET_BAD or a real fd.
src/sys/libcurl.zig CurlSockAddr extern struct, CurlSockType enum, CurlOpenSocketFunction type, CURL_SOCKET_BAD constant.
src/network/Runtime.zig Parses CLI flags, creates IpFilter, passes to connections.
src/Config.zig --block-private-networks (bool) and --block-cidrs (string, supports - prefix for allow entries) flag definitions.

Tests

18 new unit tests:

  • IPv4/IPv6 CIDR boundary matching (first/last address in range, one-past-range)
  • IPv4-mapped IPv6 bypass prevention (::ffff:10.0.0.1 matched against IPv4 rules)
  • Fail-closed on unknown address families
  • Custom CIDR parsing and matching
  • Allow-list exclusions: exempt specific IPs from private and custom block rules
  • CIDR parsing with - prefix produces correct allow/block lists
  • opensocketCallback integration (blocked returns CURL_SOCKET_BAD, allowed returns valid fd)

Limitations

  • libcurl connections only: Covers all protocols that go through libcurl's socket layer (HTTP/1.1, HTTP/2, and QUIC/HTTP3 if enabled in the future. CURLOPT_OPENSOCKETFUNCTION fires for all transports including UDP). Anything that opens sockets outside of libcurl would not be covered.
  • No hostname blocking: This is IP-level only, by design. URL-pattern blocking is a separate concern.

Block outbound HTTP requests to specified IP ranges before TCP handshake
using libcurl CURLOPT_OPENSOCKETFUNCTION callback. Fires after DNS
resolution, reads resolved IP directly from sockaddr, does bitwise CIDR
comparison. Fail-closed: unknown address families are blocked.

--block_private_networks blocks RFC1918, localhost, link-local, ULA.
--block_cidrs blocks additional comma-separated CIDRs.
IPv4-mapped IPv6 (::ffff:x.x.x.x) is unwrapped to prevent bypass.
@karlseguin karlseguin requested a review from mrdimidium March 24, 2026 03:32
@mrdimidium mrdimidium self-assigned this Mar 24, 2026
@lucoffe
Copy link
Copy Markdown
Contributor Author

lucoffe commented Mar 24, 2026

please hold review

CIDRs prefixed with '-' are treated as allow rules that exempt matching
IPs from blocking. Allow rules take precedence over both
--block_private_networks and custom block CIDRs.

Example: --block_private_networks --block_cidrs -10.0.0.42/32
blocks all private ranges except 10.0.0.42.

Adds 3 new tests for allow-list behavior.
@lucoffe
Copy link
Copy Markdown
Contributor Author

lucoffe commented Mar 24, 2026

Added allow-list support to --block_cidrs. Prefix a CIDR with - to exempt matching IPs from blocking:

--block_private_networks --block_cidrs -10.0.0.42/32

This blocks all private ranges except 10.0.0.42. Allow rules take precedence over both --block_private_networks and custom block CIDRs, so evaluation order is: allow list (immediate pass) -> private ranges -> custom blocks -> pass.
Works for both IPv4 and IPv6, e.g. --block_cidrs 10.0.0.0/8,-10.0.0.0/24,-fc00::1/128.

Sorry for the hiccup

lucoffe and others added 2 commits March 24, 2026 10:34
Rename --block_private_networks to --block-private-networks and
--block_cidrs to --block-cidrs to match the existing flag naming
convention (e.g. --http-proxy, --proxy-bearer-token).
@lucoffe
Copy link
Copy Markdown
Contributor Author

lucoffe commented Mar 24, 2026

Also renamed flags to use dashes (--block-private-networks, --block-cidrs) instead of underscores, matching the updated CLI convention (--http-proxy, --proxy-bearer-token, --http-max-concurrent, etc.).

Ready for review 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants