Add an egress CIDR allowlist to the SSRF guard (closes #204)
All checks were successful
check / check (push) Successful in 3m4s
All checks were successful
check / check (push) Successful in 3m4s
The SSRF blocklist had no escape hatch, so the thing webhooker is mostly for — taking a public webhook and forwarding it to something on your own network — could not be configured at all. Every private address, Docker sibling and loopback service was permanently unreachable as a delivery destination. ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery targets may reach despite the default blocklist. It is an allowlist and only ever adds destinations: there is no boolean, and no value disables SSRF protection wholesale. Empty, it adds nothing and the guard permits and refuses the same addresses it did before, save the two spellings named below. A small set of addresses is refused before the allowlist is consulted, so no supplied CIDR opens one — not the exact address, not a supernet, not 0.0.0.0/0 or ::/0. alwaysBlockedNetworks in internal/delivery/ssrf.go is the authoritative list and states the membership criterion in full; it is deliberately not copied here, because a copy drifts out of date. In short: the provider fixes the address, so a host route for it collides with nothing the operator runs, and reaching it discloses credentials or user data. A publicly routable address never qualifies however well it meets both — nothing in this set can be reopened, so blocking one here would leave the operator no escape hatch at all. Those belong in blockedNetworks, which an allowlist can override. Every entry is already inside the default blocklist, which is what makes it unconditional rather than newly blocked, with two exceptions that are the one behaviour change visible when the allowlist is unset: ::a9fe:a9fe and 64:ff9b::a9fe:a9fe, the IPv4-compatible and NAT64 spellings of 169.254.169.254, were reachable before and are refused now. net.IPNet.Contains normalises only the IPv4-mapped form via To4(), so 169.254.0.0/16 never matched those two. The ten it does cover now report a metadata error rather than the generic private-range one. The policy now lives in one function, Guard.checkIP, which both target-creation validation and the delivery dialer call. The two paths previously decided separately, which is how they came to disagree about a destination. The guard is built once from config and injected via fx into both the handlers and the delivery engine, so there is a single instance and a single answer. A set-but-unparseable value aborts startup naming the variable, reusing the existing envPrefixList parser. A non-empty list is logged at startup with the blocks spelled out, not counted, so the hole is visible in the log of any deployment that has one. Tests: an allowlisted loopback CIDR both validates and delivers to a live server (and the same URL still fails without the allowlist); a private address outside the listed block stays refused on both paths; every unconditionally blocked address stays refused on both paths under an allowlist that covers it, and the set itself is pinned entry by entry; public addresses are unaffected either way; and config coverage for parsing, startup abort, and the warning's contents.
This commit is contained in:
127
README.md
127
README.md
@@ -114,6 +114,118 @@ TTY detection, and security headers are always applied.
|
||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
|
||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||
|
||||
#### Allowing egress to your own network
|
||||
|
||||
By default every delivery target must resolve to a public address, and
|
||||
a handful of public ones are refused too. The private and reserved
|
||||
ranges — RFC 1918, loopback, CGNAT, link-local and the rest — are
|
||||
refused, which stops a target from being used to make webhooker probe
|
||||
the network it sits in; so are the cloud metadata endpoints listed
|
||||
below that happen to live on public addresses.
|
||||
|
||||
That default is also inconvenient for the thing webhooker is mostly
|
||||
for: taking a public webhook and forwarding it to something on your own
|
||||
network. A container on the same Docker network, a box on `10.x`, a
|
||||
service on `127.0.0.1` — all refused, until you name them.
|
||||
|
||||
`ALLOWED_EGRESS_CIDRS` is a comma-separated list of CIDR blocks (a bare
|
||||
address such as `10.0.0.7` is accepted and treated as a single host),
|
||||
for example `10.0.0.0/8, 172.17.0.0/16`. Addresses inside those blocks
|
||||
become valid delivery destinations. Everything outside them keeps the
|
||||
default answer, so this only ever adds destinations — it never removes
|
||||
any, and it cannot narrow what was already reachable.
|
||||
|
||||
**The risk, plainly.** Each block you list is a network that anyone who
|
||||
can create a delivery target can now make this process issue requests
|
||||
into, and read the response body back out of via the delivery log. That
|
||||
is server-side request forgery, deliberately enabled and scoped by you.
|
||||
A webhooker admin account is therefore as trusted as the narrowest
|
||||
thing on those networks: an unauthenticated admin panel, a database
|
||||
listening without a password, or an internal API that trusts its
|
||||
network position is reachable through it. List the smallest blocks that
|
||||
cover the destinations you actually deliver to — prefer
|
||||
`10.1.2.3/32` over `10.0.0.0/8` — and never list a block wider than the
|
||||
network you are willing to expose.
|
||||
|
||||
Listing `0.0.0.0/0` or `::/0` opens **every** other private and
|
||||
reserved range at once — loopback, RFC 1918, CGNAT, ULA, the lot. It is
|
||||
a functional off switch for everything except the addresses listed as
|
||||
unconditionally blocked below, and it makes any delivery target a probe
|
||||
into your entire network and this host's own loopback services. Do not
|
||||
list it.
|
||||
|
||||
Two things this setting cannot do:
|
||||
|
||||
- **It cannot turn the guard off.** There is no boolean, and no value
|
||||
that disables SSRF protection wholesale. The guard is always on and
|
||||
the list is always an allowlist; an empty list (the default) means
|
||||
every private and reserved range stays refused. Note that
|
||||
`0.0.0.0/0` gets you most of the way there anyway, per above.
|
||||
- **It cannot open link-local, or a cloud metadata endpoint that
|
||||
discloses credentials or user data.** An address is on the list below
|
||||
when both of these hold: the provider fixes it, so it cannot collide
|
||||
with anything you run; and reaching it hands out credentials, user
|
||||
data or bootstrap material. Those stay blocked no matter what you
|
||||
list, including when you list them outright or list a supernet such
|
||||
as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as
|
||||
best effort rather than a guarantee — it is a hand-maintained list
|
||||
and the caveat below the table applies:
|
||||
|
||||
| Blocked unconditionally | What it is |
|
||||
| ----------------------- | ---------- |
|
||||
| `169.254.0.0/16` | IPv4 link-local, carrying `169.254.169.254` (AWS, Azure, DigitalOcean, Hetzner, OpenStack and others — not Alibaba, which uses `100.100.100.200` below) |
|
||||
| `fe80::/10` | IPv6 link-local |
|
||||
| `fd00:ec2::254/128` | AWS IPv6 IMDS |
|
||||
| `fd00:ec2::23/128` | AWS EKS Pod Identity Agent |
|
||||
| `fd20:ce::254/128` | GCP metadata for IPv6-only instances |
|
||||
| `fd00:c1::a9fe:a9fe/128` | Oracle OCI IMDS over IPv6 |
|
||||
| `fd00:42::42/128` | Scaleway metadata over IPv6 |
|
||||
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
|
||||
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
|
||||
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
|
||||
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
|
||||
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
|
||||
|
||||
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
|
||||
`169.254.0.0/16` entry. Reaching any of these is credential or
|
||||
user-data theft rather than delivery to an internal service. Every
|
||||
entry outside the two link-local blocks is a single address, so
|
||||
blocking it costs you nothing else on the network around it.
|
||||
|
||||
The six ULA entries, all inside `fd00::/8`, are why this matters in
|
||||
practice: `fd00::/8` is an ordinary block to allowlist for your own
|
||||
IPv6 network, and without those host routes that one line would hand
|
||||
out cloud credentials on five providers at once. There is only one
|
||||
`/8` involved — `fd20:ce::254` masks into `fd00::/8` as well — and
|
||||
the six endpoints are five providers because AWS appears twice, IMDS
|
||||
and EKS Pod Identity. Several of them are described as "link-local" —
|
||||
or even "localhost" — in their own vendor's documentation, but they
|
||||
are ULAs and `fe80::/10` does not cover them.
|
||||
|
||||
Every entry above is reserved space. All but the last two are already
|
||||
refused with no allowlist set, and listing them here is only what
|
||||
stops an allowlist from reopening them; the last two are the alternate
|
||||
encodings, which the default blocklist does not match. A publicly
|
||||
routable metadata address is not listed here, because nothing on this
|
||||
list can be reopened and blocking one that way would leave you no
|
||||
escape hatch at all.
|
||||
|
||||
This list is not exhaustive of every cloud's metadata address — if
|
||||
yours is not here, do not allowlist the block that contains it.
|
||||
|
||||
The list is applied at one place in the code, which both target
|
||||
creation and delivery consult, so a URL that the target form accepts is
|
||||
one that delivery will actually attempt — the two cannot disagree.
|
||||
Delivery re-resolves and re-checks the destination at dial time, so a
|
||||
hostname that resolves to an allowed address during validation and a
|
||||
different one later (DNS rebinding) is still refused unless the new
|
||||
address is also allowed.
|
||||
|
||||
A set but unparseable value aborts startup. When the list is non-empty
|
||||
webhooker logs it at startup, blocks and all, so the hole is visible in
|
||||
the log of any deployment that has one.
|
||||
|
||||
#### Metrics credentials
|
||||
|
||||
@@ -272,8 +384,9 @@ additionally be a number in the range 1–65535,
|
||||
`RECEIVER_RATE_LIMIT` must be at least 1,
|
||||
`RETENTION_SWEEP_INTERVAL` must be greater than zero (it is a ticker
|
||||
period, so `0s` or a negative value would crash the reaper after
|
||||
startup), and every entry in `TRUSTED_PROXIES` must be a CIDR block or
|
||||
a bare IP address. `SESSION_IDLE_TIMEOUT` is the exception: a
|
||||
startup), and every entry in `TRUSTED_PROXIES` and
|
||||
`ALLOWED_EGRESS_CIDRS` must be a CIDR block or a bare IP address.
|
||||
`SESSION_IDLE_TIMEOUT` is the exception: a
|
||||
non-positive value there means idle expiry is disabled, not invalid.
|
||||
|
||||
Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the
|
||||
@@ -2332,7 +2445,15 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked
|
||||
both at target creation time (URL validation) and at delivery time
|
||||
(custom HTTP transport with SSRF-safe dialer that validates resolved
|
||||
IPs before connecting, preventing DNS rebinding attacks)
|
||||
IPs before connecting, preventing DNS rebinding attacks). Both paths
|
||||
route through a single decision function, so they cannot disagree
|
||||
about a destination. An operator can permit specific blocks with
|
||||
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
|
||||
guard cannot be switched off, and link-local plus a
|
||||
[pinned set](#allowing-egress-to-your-own-network) of known cloud
|
||||
metadata endpoints — several of which are ULAs or public addresses
|
||||
outside link-local — stay blocked whatever is listed, though listing
|
||||
`0.0.0.0/0` or `::/0` does open every other private range
|
||||
- **Login limiting is inverted, deliberately.** The login `POST` has
|
||||
no pre-emptive rate limiter in front of it. Credentials are
|
||||
verified first and only a _failed_ attempt spends budget, so a
|
||||
|
||||
Reference in New Issue
Block a user