ecluse
Safe HaskellNone
LanguageGHC2021

Ecluse.Security.Egress

Description

The resolving-fetch SSRF guard: a connection-time recheck of every resolved outbound IP.

The pure Ecluse.Security layer gates an outbound target by host — the allowlist (isAllowedUpstreamHost) and the internal-range block over an IP literal (isBlockedTarget). What it structurally cannot see is where a DNS name resolves: an allowlisted hostname whose A/AAAA record points at 169.254.169.254, loopback, or an RFC1918 address would pass the pure gate and then have the proxy connect to an internal service. That is the classic SSRF-via-DNS (and DNS-rebinding) gap.

This module closes it at the one place it can be closed — the http-client connection hook. guardedManagerSettings wraps the manager's connect function so that, for every outbound connection, the destination host is resolved and every resolved address is tested against the same internal-range block before the socket is used; a connection to an internal address is refused with a BlockedTarget rather than opened. Because the check runs at connect time (not at URL-build time), it sees the address actually being dialled, narrowing the resolve-then-connect TOCTOU window a separate up-front resolution would leave wide.

The host allowlist stays where it belongs — gating the URL before a request is ever built (see Ecluse.Server.Pipeline) — since at the connection hook only the bare host and port are known, not whether it is a sanctioned upstream. This layer is purely the resolved-IP backstop to that allowlist, the security.md invariant 3 "defence-in-depth behind invariant 2".

The recheck is origin-aware: newGuardedTlsManager is for the untrusted origins (the public-upstream fetch and every artifact stream), while the trusted private upstream — an operator-configured target that may legitimately live on an internal address — uses the unguarded newTrustedTlsManager. Only an attacker-influenced target needs the backstop, so only those fetches carry it (see security.md → "Network egress is a shared responsibility").

Synopsis

The guarded manager

guardedManagerSettings :: LoweredHostSet -> ManagerSettings -> ManagerSettings Source #

Wrap a base ManagerSettings so every outbound connection is refused if its destination host resolves to a blocked internal address.

Both the plain (managerRawConnection) and TLS (managerTlsConnection) connect functions are wrapped: before the base connector opens the socket, the host is resolved and blockedResolvedAddrs tests every resolved address against the internal-range block; any hit throws BlockedTarget and no socket is opened.

allowedInternal is the same per-host opt-in the pure block honours, so an operator who deliberately points the proxy at an internal upstream by name can opt that host's resolved address in here too. A host that does not resolve, or resolves only to permitted addresses, is connected exactly as the base settings would — this adds a gate, never a behaviour change for legitimate targets.

newGuardedTlsManager :: LoweredHostSet -> IO Manager Source #

Build a TLS-capable Manager guarded by the resolved-IP recheck.

The default tlsManagerSettings wrapped by guardedManagerSettings — the data-plane manager the composition root installs for the untrusted origins (the public-upstream metadata fetch and every artifact stream), so the resolved-IP recheck applies there. The trusted private upstream uses newTrustedTlsManager instead.

The trusted manager

newTrustedTlsManager :: IO Manager Source #

Build a plain TLS-capable Manager with no resolved-IP recheck, for the trusted private upstream.

The private base URL is operator-configured and deliberately trusted (it may legitimately resolve to an internal address — a registry on the private network), so it is not subject to the internal-range recheck the public/artifact fetches carry. The trust boundary is per origin: only an untrusted target (a public upstream, or a public dist.tarball) can steer the proxy somewhere unintended, so only those go through newGuardedTlsManager.

The connection-time refusal

data BlockedTarget Source #

Raised when an outbound connection's destination host resolves to an internal address the proxy must not reach.

Carries the host name that was being dialled and the blocked resolved literals, so a refusal is diagnosable (the operator sees which name resolved to what internal address). It is thrown from the connection hook before the socket is used, so it surfaces to the fetch caller exactly as a connection failure would — streamUpstreamWhen treats it as a recoverable miss on the private-origin fetch, and the buffered fetches as an upstream error — never a served body.

Constructors

BlockedTarget 

Fields

The resolved-IP decision (pure)

blockedResolvedAddrs :: LoweredHostSet -> [SockAddr] -> [Text] Source #

The blocked IP literals among a set of resolved socket addresses.

Each SockAddr is converted directly to an iproute IP and tested with the shared internal-range block (isBlockedIP) under the given per-host opt-in — the same block and exemption the pure host layer applies, so a resolved address gates against identical ranges. The result is the (possibly empty) list of canonical literals that were refused, for the BlockedTarget diagnostic: empty means every resolved address is permitted. A non-IP address (a Unix socket) cannot be an outbound HTTP target and is ignored.

Exposed pure so the connection-hook decision can be unit-tested over constructed addresses without performing DNS or opening a socket.