Raxol.Core.Outbound (Raxol Core v2.8.0)

Copy Markdown View Source

The outbound target policy: vet a URL before anything opens a socket to it.

Anything in this repository that reaches a host the caller (or a model, or a config file in a cloned workspace) had a say in goes through vet/2 first. Two of the five rules the policy states live here, because they need :inet and nothing else:

  1. The scheme and the port are allowed. :schemes defaults to [:https] and :ports to the ports an HTTP server listens on; a caller that documents http, or a fixed non-standard port, passes it explicitly. Without the port rule a vetted target is still an arbitrary-port prober against public hosts, because every other rule here judges the ADDRESS and none of them reads the port the caller is about to dial.
  2. Every resolved address is outside the reject set. Both families are resolved, and the whole request is refused if ANY answer is rejected, rather than the first: a host with one public and one loopback record must not be reachable by luck of resolver ordering. A lookup that FAILED is not an empty answer: a nameserver that SERVFAILs the A query while answering AAAA would otherwise choose which half of its own records was vetted, so a partial failure refuses the whole request.

The third rule is the caller's, and this module is what makes it possible: vet/2 returns the addresses it checked, and those are the addresses the caller MUST dial. Validating a hostname and then handing that hostname to an HTTP client re-resolves it and reopens the window a changed DNS record walks through. A caller that ignores :addresses and dials :uri has the same rebinding gap it had before calling.

Rules 4 (no redirect following) and 5 (bounded time, size and concurrency) belong to whoever owns the request, since neither is expressible here.

The address list, and why it is bounded

:addresses carries the vetted answers, deduplicated, interleaved by family and capped at 8. A caller tries them in turn the way :gen_tcp does when it is handed a name, which is the failover pinning would otherwise lose.

Everything the resolver said is CHECKED; only the dial list is bounded, so padding an RRset cannot walk a blocked address past the check by putting it beyond the cap. The cap is there because Raxol.Web3.Dial gives EACH address the full connect timeout (5000ms by default) in turn, so an uncapped list is a multiplier a hostile authoritative nameserver controls: 64 padded answers is 320 seconds of serial connects behind one call. Duplicates buy a repeated attempt against the same dead socket, so they go too. Interleaving is what makes the cap safe rather than a v6 outage: A-then-AAAA concatenated and cut at 8 drops every AAAA answer for a host with 8 or more A records.

The reject set

blocked?/1 is the policy, and it is a predicate over :inet address tuples rather than over strings, so a literal in a URL and a resolver answer are judged by the same code. It refuses loopback, link-local, private, carrier-grade NAT, unspecified, multicast and reserved ranges, the blocks that exist only on paper (IETF protocol assignments, the v4 and v6 documentation ranges, benchmarking, discard-only) and the 6to4 relay anycast address, plus every form that smuggles a v4 address through a v6 literal: IPv4-mapped, IPv4-compatible, IPv4-translated, NAT64 (the /96 and the RFC 8215 local-use prefix share one clause) and 6to4, which carries the address in a different pair of groups than the rest. Teredo is refused outright rather than decomposed, since it obfuscates the addresses it tunnels. Anything that is not an address tuple is refused, so a malformed answer fails closed.

The generic IPv6 clause is LAST, and the order is load-bearing rather than tidy: it answers false for 6to4, NAT64 and every other prefix that embeds or reserves an address, so moving it above them makes 2002:a9fe:a9fe:: — a working route to the cloud metadata address — reachable, silently.

Injecting a resolver

:resolver replaces :inet.getaddrs/3 with any (charlist, :inet | :inet6, timeout -> {:ok, [address]} | {:error, term}), or the 2-arity form for a resolver that answers from a literal and needs no budget. That is the seam a resolver which changes its answer between calls is tested through, which is the test rule 3 exists for and which cannot be written against the real resolver. An IP literal never reaches the resolver at all, injected or not, so skipping DNS skips no part of the check.

Summary

Types

Why resolution produced no vetted address.

Functions

Whether an :inet address tuple is outside the public internet.

Resolve a host to every address it answers with: {:ok, addresses} or {:error, reason}.

Vet a URL: {:ok, vetted} or {:error, reason}.

Types

address()

@type address() :: :inet.ip_address()

dns_reason()

@type dns_reason() :: :nxdomain | {:lookup_failed, term()}

Why resolution produced no vetted address.

:nxdomain is an answer: the name has no address in either family. {:lookup_failed, reason} is the absence of one, and the two are not interchangeable — a breaker that quarantines an origin for a nonexistent host should not quarantine it for a resolver hiccup.

reason()

@type reason() ::
  :invalid_url
  | {:blocked_address, String.t()}
  | {:dns_failed, {String.t(), dns_reason()}}

resolver()

@type resolver() ::
  (charlist(), :inet | :inet6 -> {:ok, [address()]} | {:error, term()})
  | (charlist(), :inet | :inet6, timeout() ->
       {:ok, [address()]} | {:error, term()})

vetted()

@type vetted() :: %{uri: URI.t(), addresses: [address()], hostname: String.t()}

Functions

blocked?(arg1)

@spec blocked?(address() | term()) :: boolean()

Whether an :inet address tuple is outside the public internet.

Public because this predicate is the policy, and is tested directly.

resolve(host, resolver \\ &:inet.getaddrs/3, opts \\ [])

@spec resolve(String.t(), resolver(), keyword()) ::
  {:ok, [address()]} | {:error, dns_reason()}

Resolve a host to every address it answers with: {:ok, addresses} or {:error, reason}.

An IP literal never reaches the resolver; a name is resolved over BOTH families, because a host with only an AAAA record must not pass by an empty A lookup. No address is judged here, and nothing is deduplicated or capped: a caller reaching for this instead of vet/2 is opting out of the policy and gets exactly what the resolver said.

A family whose lookup FAILED is not a family with no records, and the failure wins over the family that answered. See dns_reason/0.

:timeout_ms is the budget for the whole call, default 5000. It is a deadline rather than a per-family timeout: the AAAA lookup is given whatever the A lookup left, so two slow families cost one budget and not two.

vet(url, opts \\ [])

@spec vet(String.t(), keyword()) :: {:ok, vetted()} | {:error, reason()}

Vet a URL: {:ok, vetted} or {:error, reason}.

:hostname is the host with any IPv6 brackets removed, which is the form SNI, a certificate match and a Host header want. :uri is the parsed URL, unmodified.

  • :schemes - allowed schemes as atoms, default [:https]
  • :ports - allowed ports, default [80, 443, 8080, 8443]. A port outside it is refused as :invalid_url, the same answer an unallowed scheme gets, because it is the same answer to a caller: not a target we dial.
  • :resolver - a resolver/0, default &:inet.getaddrs/3
  • :timeout_ms - the total budget for resolution, default 5000. Both families are looked up under it: the second gets whatever the first left, so the whole of vet/2 returns within it plus the URL parse.