| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
Ecluse.Core.Security.Host
Description
Outbound-request guards for the proxy's data plane: defending where the proxy fetches.
Écluse builds outbound HTTP requests from two untrusted sources -- __client-supplied
package identifiers (the request path) and upstream-supplied artifact
locations__ (a packument's dist.tarball). This module provides the pure guard layer
that keeps the proxy from being steered by hostile input.
Where the proxy fetches: isAllowedUpstreamHost restricts outbound fetches
to the configured upstream host:port pairs, and isBlockedTarget rejects internal
address ranges (cloud instance metadata, loopback, RFC1918) that the proxy's network
position can otherwise reach. Together they are the SSRF gate: a target must be
both on the allowlist and not an internal address. The two compare different
projections of a target on purpose: authorisation compares the full authority
(HostPort, the host with its effective port, 443 when none is written), because
the fetch dials the port too; the internal-range block classifies the bare host
alone, because an address is internal regardless of port.
Synopsis
- data AllowedHostPorts
- allowedHostPorts :: Set HostPort -> AllowedHostPorts
- isAllowedUpstreamHost :: AllowedHostPorts -> HostPort -> Bool
- isBlockedTarget :: [IPRange] -> Text -> Bool
- isBlockedIP :: [IPRange] -> IP -> Bool
- parseBlockedRange :: Text -> Maybe IPRange
- data Origin
- tarballHostAllowed :: AllowedHostPorts -> Origin -> AllowedHostPorts -> [IPRange] -> Maybe HostPort -> Maybe HostPort -> Bool
- data TarballHostGate = TarballHostGate {}
- tarballHostGate :: [Text] -> Maybe Text -> Text -> Maybe Text -> TarballHostGate
Outbound host:port allowlist
data AllowedHostPorts Source #
The host:port pairs the host guards authorise, each host normalised to its
canonical key.
The type is opaque, and allowedHostPorts is its only constructor: a value of
this type therefore carries the proof that every entry is already canonicalised, so
isAllowedUpstreamHost canonicalises only the incoming host and the match cannot
be bypassed by an un-normalised configuration set. Each entry authorises exactly
its own pair: an entry built from a URL with no explicit port authorises port 443
alone (hostPortAddress bakes the default in), never the same host on any other
port.
Instances
| Show AllowedHostPorts Source # | |
Defined in Ecluse.Core.Security.Host Methods showsPrec :: Int -> AllowedHostPorts -> ShowS # show :: AllowedHostPorts -> String # showList :: [AllowedHostPorts] -> ShowS # | |
| Eq AllowedHostPorts Source # | |
Defined in Ecluse.Core.Security.Host Methods (==) :: AllowedHostPorts -> AllowedHostPorts -> Bool # (/=) :: AllowedHostPorts -> AllowedHostPorts -> Bool # | |
allowedHostPorts :: Set HostPort -> AllowedHostPorts Source #
Normalise a set of configured upstream authorities to the canonical key form
the host guards take, yielding an AllowedHostPorts.
A plain DNS name is folded to lower case (hostnames are case-insensitive), so the
guards match an incoming host against the configuration regardless of how either
was spelled. A host that parses as an IP literal is additionally rendered to its
single canonical literal (see canonicalHostKey), so equivalent spellings of one
address (compressed versus expanded IPv6, differing case) collapse to one key. An
operator who opts in 0:0:0:0:0:0:0:1 therefore matches a literal ::1 rather than
missing it on a textual difference. Ports are already numeric and pass through
untouched.
isAllowedUpstreamHost :: AllowedHostPorts -> HostPort -> Bool Source #
Whether target dials one of the configured upstream authorities.
The first guard on every outbound fetch: the proxy talks to its configured
private/public upstreams and mirror target, and nothing else -- so a target
derived from a packument's dist.tarball (or anywhere else) is fetched only if its
host and effective port appear together in allowed. Matching the pair rather
than the host alone is load-bearing: the fetch dials the full authority, so an
allowlisted host on an attacker-chosen port (registry.npmjs.org:9443) is an
unauthorised target, not a variant of an authorised one. The host match is exact
and case-insensitive, since DNS hostnames are; ports compare numerically, and
hostPortAddress already folded an absent port and a written :443 to the same
value. An empty host is never allowed. This is the allowlist half of the SSRF
gate; pair it with isBlockedTarget for the internal-range half.
The allowlist is an AllowedHostPorts, so it is already normalised and only the
incoming host is folded here -- through the same canonicalHostKey the set was
built with, so an IP-literal entry matches regardless of how either side spells the
address.
Internal-range block
isBlockedTarget :: [IPRange] -> Text -> Bool Source #
Whether host is an internal address the proxy must not fetch.
A proxy sits in a privileged network position, so an attacker who can steer a
fetch (see the module header) aims it at addresses only the proxy can reach: the
cloud instance-metadata endpoint (169.254.169.254), loopback, or the private
network (RFC1918). This blocks, by parsing host as a literal IP and testing it
against:
- link-local
169.254.0.0/16(which contains the169.254.169.254metadata address) and IPv6fe80::/10; - loopback
127.0.0.0/8and IPv6::1; - unspecified / this-host
0.0.0.0/8and IPv6::--0.0.0.0is not a no-op target: on Linux a connect to it reaches a loopback-bound service, so it is a loopback-equivalent that must be blocked alongside127.0.0.0/8; - RFC1918 private
10.0.0.0/8,172.16.0.0/12, and192.168.0.0/16; - CGNAT shared
100.64.0.0/10(RFC 6598) -- carrier-grade NAT space some cloud fabrics route internally; - IPv6 unique-local
fc00::/7(RFC 4193) -- the private-network IPv6 analogue, which contains the AWS IMDSv6 metadata endpointfd00:ec2::254; - every range in
additionalRanges, the operator-configured extension of this fixed set (ECLUSE_EGRESS__ADDITIONAL_BLOCKED_RANGES) -- a deployment's own internal space this module cannot know about in advance.
A host that is not an IP literal (a DNS name) is not blocked here:
name-based targets are constrained by the isAllowedUpstreamHost allowlist
instead. The resolve-to-internal class (an allowlisted name resolving to an
internal address) is closed by the validating-TLS manager authenticating the
dialled host (Egress), not by re-testing the resolved IP:
this pure check blocks only an internal IP literal written into the host.
isBlockedIP :: [IPRange] -> IP -> Bool Source #
Whether an IP falls in a blocked internal range: the fixed blockedRanges
set together with the caller-supplied additionalRanges.
The single source of record for the internal-range decision, used by the literal
block (isBlockedTarget) on the dist.tarball host gate. An IPv6 address that
embeds an IPv4 address is first decoded to that embedded IPv4 and tested against
the IPv4 ranges: an embedded internal literal (e.g. ::ffff:169.254.169.254, or
its NAT64 spelling 64:ff9b::a9fe:a9fe) is a recognised SSRF smuggling form, so
it must be caught by the IPv4 block rather than slip through as an unrelated
IPv6 address.
The decoded embeddings are exactly the fixed-prefix forms: IPv4-mapped
::ffff:a.b.c.d and IPv4-compatible ::a.b.c.d (RFC 4291), the NAT64
well-known prefix 64:ff9b::/96 (RFC 6052), and the NAT64 local-use prefix
64:ff9b:1::/48 (RFC 8215). An RFC 6052 network-specific translation prefix
cannot be enumerated here: it is operator-chosen from the operator's own unicast
space, so nothing in the address marks it as an embedding. An operator whose
fabric translates under such a prefix extends the block with additionalRanges
(ECLUSE_EGRESS__ADDITIONAL_BLOCKED_RANGES) instead.
parseBlockedRange :: Text -> Maybe IPRange Source #
Parse one operator-configured ECLUSE_EGRESS__ADDITIONAL_BLOCKED_RANGES entry (a
single CIDR, e.g. "203.0.113.0/24" or "2001:db8::/32") into an IPRange, or
Nothing for anything malformed.
A total wrapper over iproute's own Read instance for IPRange: that
instance's underlying parser (parseIPRange) already fails by returning no
parse rather than calling error, so readMaybe over it is safe -- unlike the
partial IsString instance (blockedRanges relies on for its own compile-time
literals, where a malformed literal would be a build-time error, never runtime
input). This is the only way the config decoder is meant to turn operator text
into an IPRange: a malformed entry must fail closed at boot, never be silently
dropped or accepted as an unblocked range.
Tarball-host gate
The trust of the origin a dist.tarball is being served from: the
operator-configured private upstream is TrustedOrigin, and the public upstream,
together with every artifact location an attacker could influence, is UntrustedOrigin.
The distinction governs the literal internal-range block alone (the cheap pure
defence-in-depth on the host gate). The trusted private origin is deliberately exempt
from it: a private registry may legitimately live on an internal address, and only an
untrusted target can be steered there. It never relaxes the host allowlist or the
same-authority clause, which gate both origins identically, so a trusted origin's
dist.tarball is still constrained to its own allowlisted host:port pair.
Constructors
| TrustedOrigin | The operator-configured private upstream: exempt from the literal internal-range block. |
| UntrustedOrigin | The public upstream, and any attacker-influenceable target: subject to the literal internal-range block. |
Instances
Arguments
| :: AllowedHostPorts | The ecosystem's canonical artifact authorities, same-host-equivalent. |
| -> Origin | |
| -> AllowedHostPorts | The |
| -> [IPRange] | The operator-configured ranges extending the fixed internal-range block (untrusted origin). |
| -> Maybe HostPort | The authority that served the packument, when one could be extracted. |
| -> Maybe HostPort | The authority of the candidate |
| -> Bool |
Whether a dist.tarball authority may be fetched, given the origin's trust,
the policy, the authority that served the packument, and the configured guards.
This is the policy half of the dist.tarball defence; it never replaces the host
allowlist or the literal internal-range block but composes on top of them, so the
answer is the conjunction of three independent checks and over-blocking is the
fail-safe:
- the target must be on the
host:portallowlist (allowed), as every outbound target is: adist.tarballauthority off the allowlist is refused outright; - its host must not be an internal-address literal (the fixed range set plus the
operator-configured
additionalBlockedRanges), the cheap pure defence-in-depth, but aTrustedOriginis exempt from this clause (seeOrigin); and - it must equal the packument origin's authority, host and port both -- an
upstream's
dist.tarballis server-chosen data (seedocs/architecture/security.md→ "Whydist.tarballis honoured"), so a tarball on a different host, or on the same host at a different port, is refused even when that pair is allowlisted. The one equivalence is the ecosystem's own canonical artifact hosts (ecosystemHosts, adapter-declared: npm has none, PyPI's isfiles.pythonhosted.org): a host the ecosystem serves artifact bytes from by design passes the same-authority clause, while staying allowlist-gated and internal-range-gated like any other target.
The allowlist and same-authority clauses gate both origins identically; only the
internal-range clause is origin-aware, so a TrustedOrigin is never let past its own
allowlisted authority or onto a different one than its metadata.
Hosts are compared by their canonical key (case-folded, and for an IP-literal the
single canonical literal; see canonicalHostKey), as the host guards are; ports
compare numerically, with no written port meaning 443 (hostPortAddress). Either
side arriving as Nothing -- a URL from which no dialable authority could be
extracted -- refuses the fetch: an authority the gate cannot compare is an
authority it never authorises. The packument side is the authority the metadata
was fetched from; only its equality to the target matters, so it need not itself
be re-validated here: it was already gated when the packument was fetched.
data TarballHostGate Source #
The mount-constant inputs to the per-request tarballHostAllowed gate, extracted
once from a mount's three configured upstream URLs so the serve path parses no URL
and builds no host set per request.
The serve-path tarball gate is on the hot artifact path (every private hit and every
public leg runs it), yet its allowlist and the private/public upstream authorities
never change after boot -- they are fixed by the mount's configuration. Recovering them
from the base URLs on each request rebuilt an AllowedHostPorts and re-parsed the base
authorities several times per artifact; precomputing them here into a TarballHostGate
collapses that to a few field reads. The only genuinely per-request authority is the
dynamic public dist.tarball, still parsed at the call site.
Constructors
| TarballHostGate | |
Fields
| |
Instances
| Show TarballHostGate Source # | |
Defined in Ecluse.Core.Security.Host Methods showsPrec :: Int -> TarballHostGate -> ShowS # show :: TarballHostGate -> String # showList :: [TarballHostGate] -> ShowS # | |
| Eq TarballHostGate Source # | |
Defined in Ecluse.Core.Security.Host Methods (==) :: TarballHostGate -> TarballHostGate -> Bool # (/=) :: TarballHostGate -> TarballHostGate -> Bool # | |
tarballHostGate :: [Text] -> Maybe Text -> Text -> Maybe Text -> TarballHostGate Source #
Build the TarballHostGate from the ecosystem's canonical artifact hosts
(empty for an ecosystem, like npm, that serves artifacts from its registry host) and a
mount's private, public, and mirror-target upstream URLs: the allowlist is the
canonicalised set of their host:port pairs, and the private and public authorities
are each extracted once with hostPortAddress.
Called once per mount at the composition root (and by test fixtures); the result is
carried on the serve dependencies so the per-request gate reads fields rather than
re-parsing URLs. A URL from which no authority extracts contributes no allowlist entry
and leaves its reference authority Nothing, so a misconfigured upstream authorises
nothing rather than something unintended; an absent private upstream or mirror
target (a serve-only mount) composes identically, contributing nothing.