Add an egress CIDR allowlist to the SSRF guard (closes #204)
All checks were successful
check / check (push) Successful in 3m28s
All checks were successful
check / check (push) Successful in 3m28s
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, the guard behaves exactly as before. A fixed 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. It is the two link-local blocks (169.254.0.0/16, fe80::/10) plus host routes for the cloud metadata endpoints that sit outside them: AWS's IPv6 IMDS at fd00:ec2::254, which lives in ordinary ULA space, and Alibaba's 100.100.100.200, which lives in CGNAT. Allowlisting fd00::/8 or 100.64.0.0/10 (Tailscale's range) is an ordinary thing for an operator to do and must not reopen instance-credential theft. The IPv4-compatible (::a9fe:a9fe) and NAT64 (64:ff9b::a9fe:a9fe) spellings of 169.254.169.254 are listed too, because To4() does not normalise them into the link-local block the way it does the IPv4-mapped form. Reaching any of these is credential theft rather than delivery to an internal service. 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:
93
README.md
93
README.md
@@ -114,6 +114,86 @@ 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.
|
||||
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.
|
||||
|
||||
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 known cloud metadata endpoint.**
|
||||
These 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`:
|
||||
|
||||
| Blocked unconditionally | What it is |
|
||||
| ----------------------- | ---------- |
|
||||
| `169.254.0.0/16` | IPv4 link-local, carrying `169.254.169.254` (AWS, GCP, Azure and others) |
|
||||
| `fe80::/10` | IPv6 link-local |
|
||||
| `fd00:ec2::254/128` | AWS IPv6 IMDS, a host route inside ULA space |
|
||||
| `100.100.100.200/32` | Alibaba Cloud metadata, a host route inside CGNAT |
|
||||
| `::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 theft
|
||||
rather than delivery to an internal service. The two host routes are
|
||||
single addresses, so blocking them costs you nothing else on the ULA
|
||||
or CGNAT networks around them. 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
|
||||
|
||||
@@ -238,8 +318,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
|
||||
@@ -1934,7 +2015,13 @@ 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 the known cloud
|
||||
metadata endpoints 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