Add an egress CIDR allowlist to the SSRF guard (closes #204) (#217)
All checks were successful
check / check (push) Successful in 3m1s
All checks were successful
check / check (push) Successful in 3m1s
This commit was merged in pull request #217.
This commit is contained in:
125
README.md
125
README.md
@@ -114,6 +114,116 @@ 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 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 +382,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 +2443,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 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