| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
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
- guardedManagerSettings :: LoweredHostSet -> ManagerSettings -> ManagerSettings
- newGuardedTlsManager :: LoweredHostSet -> IO Manager
- newTrustedTlsManager :: IO Manager
- data BlockedTarget = BlockedTarget {
- blockedHost :: Text
- blockedAddresses :: [Text]
- blockedResolvedAddrs :: LoweredHostSet -> [SockAddr] -> [Text]
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
| |
Instances
| Exception BlockedTarget Source # | |
Defined in Ecluse.Security.Egress Methods toException :: BlockedTarget -> SomeException # fromException :: SomeException -> Maybe BlockedTarget # displayException :: BlockedTarget -> String # backtraceDesired :: BlockedTarget -> Bool # | |
| Show BlockedTarget Source # | |
Defined in Ecluse.Security.Egress Methods showsPrec :: Int -> BlockedTarget -> ShowS # show :: BlockedTarget -> String # showList :: [BlockedTarget] -> ShowS # | |
| Eq BlockedTarget Source # | |
Defined in Ecluse.Security.Egress Methods (==) :: BlockedTarget -> BlockedTarget -> Bool # (/=) :: BlockedTarget -> BlockedTarget -> Bool # | |
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.