Add an egress CIDR allowlist to the SSRF guard (closes #204)
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:
2026-08-20 04:13:06 +00:00
parent f0512f1c3c
commit 7cc2e201ab
17 changed files with 1246 additions and 61 deletions

127
README.md
View File

@@ -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 165535,
`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