Key the TRUSTED_PROXIES rule to the source address seen on arrival
check / check (push) Waiting to run

The README and the TrustedProxies comment now say, once per passage,
that the list must be set to the proxy's address alone if any client
can reach webhooker or the proxy from an RFC 1918 source address,
directly or through anything that can rewrite source addresses, and
that the address to set is the remoteIP field of the http request log
line. The loopback case in the reverse-proxy checklist is now a proxy
reaching the binary bound to 127.0.0.1. Other sentences about which
clients can choose their own rate-limit key are cut.

Model: opus-5-5
This commit is contained in:
2026-10-01 21:50:35 +00:00
parent 364ce11500
commit 72b5bf32a4
2 changed files with 52 additions and 76 deletions
+45 -69
View File
@@ -142,7 +142,7 @@ TTY detection, and security headers are always applied.
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` | | `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` | | `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` | | `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. A set value replaces the default. Under the default, any client with a private address, whether it connects directly or through a trusted proxy, can choose its own rate-limit key by sending its own `X-Forwarded-For`; if any clients have private addresses, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) | | `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
| `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) | | `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 #### Allowing egress to your own network
@@ -381,35 +381,24 @@ the addresses of your reverse proxies.
inside one of these blocks; for every other peer the client identity is inside one of these blocks; for every other peer the client identity is
the connection's own address and the header is ignored. Unset (or the connection's own address and the header is ignored. Unset (or
empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`, empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`,
`172.16.0.0/12` and `192.168.0.0/16`. That covers a reverse proxy `172.16.0.0/12` and `192.168.0.0/16`. A set value replaces the default
reaching webhooker over a Docker network or a private LAN without entirely. A set but unparseable value aborts startup.
anything set. A set value replaces the default entirely. A set but
unparseable value aborts startup.
Trusting those ranges has two consequences for clients with private If any client can reach webhooker, or the proxy in front of it, from an
addresses: RFC 1918 source address (directly, or through anything that can
rewrite source addresses, such as NAT or a published container port),
set `TRUSTED_PROXIES` to the proxy's address alone, or every rate
limit, the webhook receiver's included, can be bypassed by those
clients. The address to set is the `remoteIP` field of the
`http request` log line for a request that came through the proxy.
- Any such client, whether it connects directly or through the proxy, Behind a proxy the list does not cover, every request keys on the
can choose its own rate-limit key by sending its own proxy's own address and all clients share a single bucket per limit.
`X-Forwarded-For`. A direct client's header is walked because the The receiver limits become service-wide ceilings, and the login
client is itself trusted; behind the proxy, the client's own address endpoint's failure counting collapses onto one key, so a stranger's
is skipped as a trusted hop when the chain is walked (below), so the wrong passwords throttle every other client's wrong passwords. Set
entry it wrote is taken as the client. If any of your clients have `TRUSTED_PROXIES` to that proxy's address to restore per-client
private addresses, you must set `TRUSTED_PROXIES` to the proxy's buckets.
address alone.
- A client behind the proxy that sends no `X-Forwarded-For` of its own
shares the proxy's bucket, because its own address is skipped too.
Setting the list to the proxy's address alone gives each its own
bucket.
A proxy the list does not cover, such as nginx on the same host
reaching webhooker over loopback, is not trusted: every request through
it keys on the proxy's own address and all clients share a single
bucket per limit. The receiver limits become service-wide ceilings,
and the login endpoint's failure counting collapses onto one key, so a
stranger's wrong passwords throttle every other client's wrong
passwords. Set `TRUSTED_PROXIES` to that proxy's address to restore
per-client buckets.
What it cannot do is lock the operator out. The login endpoint What it cannot do is lock the operator out. The login endpoint
verifies credentials **before** it consults any limit and charges only verifies credentials **before** it consults any limit and charges only
@@ -431,20 +420,10 @@ instead, since past such an entry the chain is not the shape assumed
here. The peer address is likewise used when the header is absent or here. The peer address is likewise used when the header is absent or
every hop in it is a trusted proxy. every hop in it is a trusted proxy.
Two operator requirements follow: Your proxy must therefore **append** the peer address to
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, HAProxy
- Your proxy must **append** the peer address to `X-Forwarded-For` `option forwardfor`, Caddy and AWS ALB by default), and must append a
(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`, bare address with no port.
Caddy and AWS ALB by default), and must append a bare address with
no port.
- Keep clients out of the list. Any address inside `TRUSTED_PROXIES`
chooses its own rate-limit key: its `X-Forwarded-For` is walked, so
it can name a different address on every request to get a fresh
bucket each time, or name another client's address to drain that
client's bucket. A block that also covers clients — the default, on
a network where clients have private addresses — makes every rate
limit, including the unauthenticated webhook receiver's, silently
bypassable by every client in the block.
#### Sessions #### Sessions
@@ -743,15 +722,14 @@ repository's `Dockerfile` and runs it. The app needs:
- **Volume:** one host directory mounted at `/var/lib/webhooker`. - **Volume:** one host directory mounted at `/var/lib/webhooker`.
- **Environment variables:** - **Environment variables:**
- `WEBHOOKER_ENVIRONMENT=prod` - `WEBHOOKER_ENVIRONMENT=prod`
- `TRUSTED_PROXIES`: Docker networks use private addresses, so the - `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to
default covers your reverse proxy on that network. Under the your reverse proxy's address alone if that address is outside
default, any client with a private address, whether it connects those ranges, or if any client can reach webhooker, or the proxy,
directly or through the proxy, can choose its own rate-limit key from an RFC 1918 source address (directly, or through anything
by sending its own `X-Forwarded-For`. If any clients have private that can rewrite source addresses, such as NAT or a published
addresses, or the network's addresses are outside the RFC 1918 container port). The `remoteIP` field of the `http request` log
ranges, set it to the proxy's address there. The `remoteIP` field line for a request that came through the proxy shows that
of the `http request` log line for a request that came through address; the health check's own lines show `::1`. See
the proxy shows it; the health check's own lines show `::1`. See
[Trusted proxies](#trusted-proxies). [Trusted proxies](#trusted-proxies).
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to `BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
@@ -815,14 +793,16 @@ reports.
behind a proxy means the `X-Forwarded-Proto` header. The block below behind a proxy means the `X-Forwarded-Proto` header. The block below
sets it; without it every request is read as plaintext and cookies sets it; without it every request is read as plaintext and cookies
ship without `Secure`. See [Configuration](#configuration). ship without `Secure`. See [Configuration](#configuration).
3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** Unset, 3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a
it covers the RFC 1918 private ranges, so a proxy on a Docker proxy it does not cover, every rate limiter keys on the proxy, so
network or a private LAN is covered and one on loopback is not. For all clients share one bucket per limit. Unset, the list is the RFC
a proxy it does not cover, every rate limiter keys on the connecting 1918 ranges, which do not cover a proxy that reaches the binary
peer, which is the proxy on every request: all clients collapse into itself over loopback (the binary bound to `127.0.0.1`). With the
one global bucket per limit and the receiver's per-IP limits become image, the address to check is the `remoteIP` field of the
service-wide ceilings. See [Trusted proxies](#trusted-proxies). If `http request` log line for a request that came through the proxy.
any clients have private addresses, list the proxy and nothing else. If any client can reach webhooker, or the proxy, from an RFC 1918
source address, set the list to the proxy's address alone. See
[Trusted proxies](#trusted-proxies).
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the 4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
port. webhooker's Origin/Referer check compares against the host it port. webhooker's Origin/Referer check compares against the host it
was given, so on any port other than 443 `$host` makes every form was given, so on any port other than 443 `$host` makes every form
@@ -2571,20 +2551,16 @@ opposite directions:
- For the **receiver** limits it costs throughput, which is the safe - For the **receiver** limits it costs throughput, which is the safe
direction to be wrong in: sharing can only make a limit bind sooner, direction to be wrong in: sharing can only make a limit bind sooner,
never let a sender past it. It matters more for the aggregate limit never let a sender past it. It matters more for the aggregate limit
than for the per-entrypoint one: when `TRUSTED_PROXIES` does not than for the per-entrypoint one: with every request keyed on the
cover the reverse proxy a production deployment is required to run proxy, the aggregate limit becomes a service-wide ceiling of 1200
behind, every request keys on the proxy, so the aggregate limit requests per minute across all senders and all entrypoints, where the
becomes a service-wide ceiling of 1200 requests per minute across all per-entrypoint limit's capacity still grows with the number of
senders and all entrypoints, where the per-entrypoint limit's entrypoints.
capacity still grows with the number of entrypoints. Any deployment
with more than a handful of busy entrypoints must make sure
`TRUSTED_PROXIES` covers its proxy.
- For the **login and password-change** limits it costs precision, not - For the **login and password-change** limits it costs precision, not
availability. Login failures from every client land in one counter, availability. Login failures from every client land in one counter,
so a stranger's wrong passwords make the operator's own wrong so a stranger's wrong passwords make the operator's own wrong
passwords answer `429` sooner; the operator's _correct_ password is passwords answer `429` sooner; the operator's _correct_ password is
never affected, because it is never counted. Production deployments never affected, because it is never counted.
should still make sure `TRUSTED_PROXIES` covers their proxy.
#### The login endpoint #### The login endpoint
+7 -7
View File
@@ -178,13 +178,13 @@ type Config struct {
// TrustedProxies is the set of networks whose members are // TrustedProxies is the set of networks whose members are
// allowed to speak for the client with X-Forwarded-For, the // allowed to speak for the client with X-Forwarded-For, the
// only forwarded header read. Unless TRUSTED_PROXIES is set it // only forwarded header read. Unless TRUSTED_PROXIES is set it
// is the RFC 1918 private ranges (defaultTrustedProxies). // is the RFC 1918 private ranges (defaultTrustedProxies); a set
// Other peers' forwarded headers are ignored and they are // value replaces them. If any client can reach the process, or
// identified by the connection's own address. Under the // the proxy in front of it, from an RFC 1918 source address
// default any client with a private address, directly or // (directly, or through anything that can rewrite source
// through a trusted proxy, can choose its own rate-limit // addresses, such as NAT or a published container port), it
// key, so where any clients have private addresses this must // must be set to the proxy's address alone, or every rate limit
// be set to the proxy hosts alone. // can be bypassed by those clients.
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
// AllowedEgressCIDRs is the set of networks a delivery target // AllowedEgressCIDRs is the set of networks a delivery target