| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
Ecluse.Core.Security
Description
Outbound-request and response-bound guards for the proxy's data plane.
É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) -- and then parses whatever an upstream
returns. This module is the pure guard layer that keeps those steps from being
steered or exhausted by hostile input. It defends three boundaries:
- Where the proxy fetches.
isAllowedUpstreamHostrestricts outbound fetches to the configured upstreamhost:portpairs, andisBlockedTargetrejects 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. - How much an upstream may cost. A
Limitsbudget plusboundedRead(abort a streamed body pastmaxBodyBytes) andcheckVersionCount/checkNestingDepth(reject an oversized or deeply-nested parsed document) bound algorithmic-complexity DoS from a hostile or compromised upstream. Every limit fails closed: exceeding one yieldsLeft, never a truncated or partial result.
The functions are pure and total; the streamed-body guard (boundedRead) is
polymorphic over the producing monad so the streaming data plane can run it in
IO while tests drive it purely. They are primitives: the fetch and serve
layers compose them at the boundary (see docs/architecture/registry-model.md
→ "Registry Abstraction" and docs/architecture/web-layer.md → "Multi-ecosystem
mounts"). Path-component safety is
shared with the router's Ecluse.Core.Server.Route (isSafeComponent); the threat
model these guards answer is recorded there too.
Synopsis
- module Ecluse.Core.Security.Host
- module Ecluse.Core.Security.Limits
- module Ecluse.Core.Security.Authority
- parseIpLiteral :: Text -> Maybe IpAddr
- isDecimal :: Text -> Bool
- isHex :: Text -> Bool
Documentation
module Ecluse.Core.Security.Host
module Ecluse.Core.Security.Limits
parseIpLiteral :: Text -> Maybe IpAddr Source #
Parse a host as an IP literal, or Nothing for a DNS name. Handles dotted-
quad IPv4 and the IPv6 forms a host realistically carries -- full eight-group form,
::-compressed forms (including ::1), and a trailing embedded IPv4 (the
a.b.c.d in ::ffff:a.b.c.d) -- which is enough to recognise the loopback,
link-local, and IPv4-mapped addresses isBlockedIP blocks. It is deliberately
not a complete IPv6 parser (no zone ids); an unrecognised literal is treated
as a name, which the host allowlist still constrains.
Only range membership is delegated to iproute (isBlockedIP); recognising
the literal stays hand-rolled on purpose. This recogniser is deliberately
lenient on the IPv4 dotted-quad: it accepts the ambiguous octet spellings a
strict IP library rejects and coerces each octet exactly as inet_aton -- and
hence a libc resolver -- does, so the block tests the address that would actually be
dialled. A 0x/0X-prefixed octet is hexadecimal, a leading-zero octet is
octal, and anything else is decimal. A leading-zero octet is therefore not
its decimal digits: 0012.0.0.1 is octal 10.0.0.1 (RFC1918, blocked), whereas
010.0.0.1 is octal 8.0.0.1 and 0127.0.0.1 is octal 87.0.0.1 (both public,
not blocked) -- matching the resolver rather than a decimal misreading. A stricter
parser that rejected these spellings would let an octal/hex spelling of an
internal address skip the block and reach the resolving fetch as a name, silently
narrowing the SSRF gate.
Two boundaries are deliberately not modelled here; such a host is simply treated as a
name, which the host allowlist constrains. First, the short inet_aton forms with
fewer than four parts (a bare 32-bit number 2130706433 / 0x7f000001, or a 127.1)
are not literals here. Second, a malformed octet (an invalid-octal 08, where 8 is not
an octal digit, or an overflowing 0400/256/0x100) is not a literal, exactly as a
resolver rejects it. A malformed IPv6 group that overflows 16 bits (fe80::1ffff) is
likewise not a literal here. Delegating literal parsing to a library would change this
lenient/strict boundary, so it is kept here.