ecluse:ecluse-core
Safe HaskellNone
LanguageGHC2021

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. 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.
  • How much an upstream may cost. A Limits budget plus boundedRead (abort a streamed body past maxBodyBytes) and checkVersionCount / 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 yields Left, 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

Documentation

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.