Ankusa.HttpClient.Egress (ankusa v0.7.0)

Copy Markdown View Source

The egress policy: which URLs Ankusa may send a hook to (CWE-918).

An HTTP sink forwards the raw webhook body to a URL somebody configured — an operator in YAML, or a tenant through the admin API. Without a policy that URL may be http://127.0.0.1:4003/... (Ankusa's own admin listener), the cloud metadata service (http://169.254.169.254/...), or any service on the private network. Ankusa.Sink.Http therefore calls validate_target/3 (via pin/3) on every attempt, so a name that resolves somewhere else on the next retry is checked again, and a deny is a permanent failure.

The policy

Ankusa.Config's egress section (YAML egress:):

  • :allow_insecure_http — false: only https URLs. true also allows http, which carries the hook in the clear
  • :deny_cidrs — an address inside any of these is refused. Default: loopback, RFC 1918, carrier-grade NAT, link-local (the metadata service), unique-local and link-local IPv6, and the unspecified addresses
  • :allow_cidrs — a deny exception: an address inside one of these passes even when a deny CIDR also holds it (["10.20.0.0/16"] for the one internal network sinks live on)
  • :allow_hosts — hostnames exempt from the CIDR check, for sinks reached by service name (["worker"]). The name is still resolved and pinned
  • :deny_hosts — hostnames refused before any lookup; wins over :allow_hosts. Default: ["metadata.google.internal"]

A name is resolved for both address families with the OTP resolver (:inet.getaddrs), and the target is refused when any of its addresses is denied and not allowed: a name with one public and one private record is a rebinding setup, not a multi-homed service. IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) are matched as the IPv4 address they stand for.

Pinning

pin/3 returns what to send instead of the URL it was given: the URL names the checked IP address and a host header carries the name, so the connection cannot go anywhere the check did not look and nothing is left to rebind. For https the connection options also carry hostname: name, which Mint uses for SNI and for the certificate check, so the peer must still present a certificate valid for the name (peer verification cannot be switched off without allow_insecure_tls, see Ankusa.HttpClient).

Every connection a pool opens to the pinned URL goes to that address: Ankusa.HttpClient.Pools keeps one pool per pinned address and hostname, and pin/3 runs again, resolving the name afresh, before every request. A name that later resolves to a denied address is refused on that request, before any pool is touched; one that resolves to another public address gets a pool of its own, and the old one is swept once idle.

Reasons in {:error, {:egress_denied, reason}} carry the host and the offending address, never the URL: it may hold credentials.

Summary

Types

What to send: url, extra request headers, and connect_options for the client.

Functions

The default policy: https only, every private, loopback and link-local range denied.

validate_target/3, returning what to send: see "Pinning".

Raise ArgumentError, naming the key, unless config.egress is a well-formed policy (Ankusa.Config calls it, and so does the instance at boot), and unless every Ankusa.Sink.Http the config names — lifecycle sinks and the static sources — passes Ankusa.Sink.Http.validate_opts!/1.

Raise ArgumentError unless policy is a well-formed policy map. See validate_config!/1.

Check url against policy: :ok, or {:error, {:egress_denied, reason}}.

Types

pinned()

@type pinned() :: %{
  url: String.t(),
  headers: [{String.t(), String.t()}],
  connect_options: keyword()
}

What to send: url, extra request headers, and connect_options for the client.

policy()

@type policy() :: %{
  optional(atom()) => term(),
  allow_insecure_http: boolean(),
  deny_cidrs: [String.t()],
  allow_cidrs: [String.t()],
  allow_hosts: [String.t()],
  deny_hosts: [String.t()]
}

reason()

@type reason() ::
  {:url, String.t()}
  | {:scheme, String.t()}
  | {:host, String.t()}
  | {:address, String.t(), String.t()}
  | {:resolve, String.t(), term()}
  | {:policy, String.t()}

resolver()

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

Functions

default_policy()

@spec default_policy() :: policy()

The default policy: https only, every private, loopback and link-local range denied.

pin(url, policy, opts \\ [])

@spec pin(String.t(), policy(), keyword()) ::
  {:ok, pinned()} | {:error, {:egress_denied, reason()}}

validate_target/3, returning what to send: see "Pinning".

Takes validate_target/3's options.

validate_config!(config)

@spec validate_config!(Ankusa.Config.t()) :: :ok

Raise ArgumentError, naming the key, unless config.egress is a well-formed policy (Ankusa.Config calls it, and so does the instance at boot), and unless every Ankusa.Sink.Http the config names — lifecycle sinks and the static sources — passes Ankusa.Sink.Http.validate_opts!/1.

validate_policy!(policy)

@spec validate_policy!(term()) :: :ok

Raise ArgumentError unless policy is a well-formed policy map. See validate_config!/1.

validate_target(url, policy, opts \\ [])

@spec validate_target(String.t(), policy(), keyword()) ::
  :ok | {:error, {:egress_denied, reason()}}

Check url against policy: :ok, or {:error, {:egress_denied, reason}}.

Options: :resolver (a (charlist, family -> {:ok, [ip]} | {:error, reason} function, default :inet.getaddrs with the timeout) and :timeout (the lookup deadline in milliseconds, default 5000).