Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a112fad71 |
@@ -147,7 +147,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 (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) |
|
| `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
|
||||||
@@ -379,48 +379,41 @@ unlocked.
|
|||||||
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
|
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
|
||||||
address such as `192.168.1.7` is accepted and treated as a single
|
address such as `192.168.1.7` is accepted and treated as a single
|
||||||
host), for example `192.168.1.7, 2001:db8::5`. It decides whose
|
host), for example `192.168.1.7, 2001:db8::5`. It decides whose
|
||||||
`X-Forwarded-For` header the rate limiters believe, so it should cover
|
`X-Forwarded-For` header the rate limiters believe, so it should name
|
||||||
the addresses of your reverse proxies.
|
the addresses of your reverse proxies and nothing else.
|
||||||
|
|
||||||
`X-Forwarded-For` is honoured **only** when the connecting peer is
|
`X-Forwarded-For` is honoured **only** when the connecting peer is
|
||||||
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. The default is
|
||||||
empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`,
|
the empty list, which trusts nobody — anything else would let any
|
||||||
`172.16.0.0/12` and `192.168.0.0/16`. That covers a reverse proxy
|
client pick its own rate limit bucket, minting a fresh one per request
|
||||||
reaching webhooker over a Docker network or a private LAN without
|
or draining someone else's. Set it to the address of your reverse
|
||||||
anything set. A set value replaces the default entirely. A set but
|
proxy, and to nothing wider. A set but unparseable value aborts
|
||||||
unparseable value aborts startup.
|
startup.
|
||||||
|
|
||||||
Trusting those ranges has two consequences for clients with private
|
That default is safe against forged headers, but leaving it unset in
|
||||||
addresses:
|
production has a cost you must know about. Production runs behind a
|
||||||
|
TLS-terminating reverse proxy, so with `TRUSTED_PROXIES` unset every
|
||||||
- Any such client, whether it connects directly or through the proxy,
|
request keys on the proxy's own address and all clients share a single
|
||||||
can choose its own rate-limit key by sending its own
|
|
||||||
`X-Forwarded-For`. A direct client's header is walked because the
|
|
||||||
client is itself trusted; behind the proxy, the client's own address
|
|
||||||
is skipped as a trusted hop when the chain is walked (below), so the
|
|
||||||
entry it wrote is taken as the client. If any of your clients have
|
|
||||||
private addresses, you must set `TRUSTED_PROXIES` to the proxy's
|
|
||||||
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,
|
bucket per limit. The receiver limits become service-wide ceilings,
|
||||||
and the login endpoint's failure counting collapses onto one key, so a
|
and the login endpoint's failure counting collapses onto one key, so a
|
||||||
stranger's wrong passwords throttle every other client's wrong
|
stranger's wrong passwords throttle every other client's wrong
|
||||||
passwords. Set `TRUSTED_PROXIES` to that proxy's address to restore
|
passwords.
|
||||||
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
|
||||||
failures, so a correct password is never throttled no matter how full
|
failures, so a correct password is never throttled no matter how full
|
||||||
the bucket is. See [Rate Limiting](#rate-limiting).
|
the bucket is. See [Rate Limiting](#rate-limiting).
|
||||||
|
|
||||||
|
The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's
|
||||||
|
address, which restores per-client buckets. webhooker logs a warning
|
||||||
|
at startup whenever `TRUSTED_PROXIES` is empty, in every environment,
|
||||||
|
because behind a proxy every client shares one bucket in `dev` and
|
||||||
|
`prod` alike. The warning is informational when nothing proxies to the
|
||||||
|
process: with no proxy in front, the peer address is the client's own
|
||||||
|
and the buckets are already per-client. See
|
||||||
|
[Rate Limiting](#rate-limiting) for what each limit shares.
|
||||||
|
|
||||||
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
|
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
|
||||||
Reverse proxies append to `X-Forwarded-For` but forward other client
|
Reverse proxies append to `X-Forwarded-For` but forward other client
|
||||||
headers verbatim, so a single-valued header is client-controlled even
|
headers verbatim, so a single-valued header is client-controlled even
|
||||||
@@ -442,14 +435,14 @@ Two operator requirements follow:
|
|||||||
(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`,
|
(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`,
|
||||||
Caddy and AWS ALB by default), and must append a bare address with
|
Caddy and AWS ALB by default), and must append a bare address with
|
||||||
no port.
|
no port.
|
||||||
- Keep clients out of the list. Any address inside `TRUSTED_PROXIES`
|
- List proxy hosts **only**. Any address inside `TRUSTED_PROXIES`
|
||||||
chooses its own rate-limit key: its `X-Forwarded-For` is walked, so
|
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
|
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
|
bucket each time, or name another client's address to drain that
|
||||||
client's bucket. A block that also covers clients — the default, on
|
client's bucket. Never list a block that also covers clients — a
|
||||||
a network where clients have private addresses — makes every rate
|
broad `10.0.0.0/8` on a network where clients live in the same range
|
||||||
limit, including the unauthenticated webhook receiver's, silently
|
makes all three limits, including the unauthenticated webhook
|
||||||
bypassable by every client in the block.
|
receiver, silently bypassable by every client in the block.
|
||||||
|
|
||||||
#### Sessions
|
#### Sessions
|
||||||
|
|
||||||
@@ -748,16 +741,10 @@ 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`: your reverse proxy's address on that Docker
|
||||||
default covers your reverse proxy on that network. Under the
|
network. The `remoteIP` field of the `http request` log line for a
|
||||||
default, any client with a private address, whether it connects
|
request that came through the proxy shows it; the health check's
|
||||||
directly or through the proxy, can choose its own rate-limit key
|
own lines show `::1`. See [Trusted proxies](#trusted-proxies).
|
||||||
by sending its own `X-Forwarded-For`. If any clients have private
|
|
||||||
addresses, or the network's addresses are outside the RFC 1918
|
|
||||||
ranges, set it to the proxy's address there. The `remoteIP` field
|
|
||||||
of the `http request` log line for a request that came through
|
|
||||||
the proxy shows it; the health check's own lines show `::1`. See
|
|
||||||
[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
|
||||||
`/var/lib/webhooker`.
|
`/var/lib/webhooker`.
|
||||||
@@ -820,14 +807,12 @@ 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. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
|
||||||
it covers the RFC 1918 private ranges, so a proxy on a Docker
|
limiter keys on the connecting peer, which behind a proxy is the
|
||||||
network or a private LAN is covered and one on loopback is not. For
|
proxy on every request: all clients collapse into one global bucket
|
||||||
a proxy it does not cover, every rate limiter keys on the connecting
|
per limit and the receiver's per-IP limits become service-wide
|
||||||
peer, which is the proxy on every request: all clients collapse into
|
ceilings. See [Trusted proxies](#trusted-proxies). List the proxy
|
||||||
one global bucket per limit and the receiver's per-IP limits become
|
and nothing else.
|
||||||
service-wide ceilings. See [Trusted proxies](#trusted-proxies). If
|
|
||||||
any clients have private addresses, list the proxy and nothing else.
|
|
||||||
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
|
||||||
@@ -1378,11 +1363,10 @@ It uses:
|
|||||||
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
||||||
sliding-window rate limiting of the password-change and webhook
|
sliding-window rate limiting of the password-change and webhook
|
||||||
receiver endpoints. The bucket is per client IP only when
|
receiver endpoints. The bucket is per client IP only when
|
||||||
`TRUSTED_PROXIES` covers the reverse proxy (by default it covers the
|
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
||||||
RFC 1918 private ranges); otherwise every client behind that proxy
|
behind that proxy shares one bucket per limit. The login endpoint
|
||||||
shares one bucket per limit. The login endpoint counts failed
|
counts failed attempts itself instead, so that a correct password is
|
||||||
attempts itself instead, so that a correct password is never
|
never throttled (see [Rate Limiting](#rate-limiting))
|
||||||
throttled (see [Rate Limiting](#rate-limiting))
|
|
||||||
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||||
`/metrics` behind basic auth
|
`/metrics` behind basic auth
|
||||||
- **[Sentry](https://sentry.io)** for optional error reporting
|
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||||
@@ -2552,48 +2536,47 @@ the tree is checked out: four checkouts have reported 3,959, 3,961,
|
|||||||
client-supplied field was cut, and that the shipped chain's stack
|
client-supplied field was cut, and that the shipped chain's stack
|
||||||
arrived uncut — never the numbers.
|
arrived uncut — never the numbers.
|
||||||
|
|
||||||
Every limiter here — receiver, login, password change, delivery replay
|
Every limiter here — receiver, login, and password change — identifies
|
||||||
and event resubmit — identifies the client the same way, through one
|
the client the same way, through one shared key function: the
|
||||||
shared key function: the connection's own address, unless the peer is
|
connection's own address, unless the peer is listed in
|
||||||
inside `TRUSTED_PROXIES`, in which case the forwarded client address is
|
`TRUSTED_PROXIES`, in which case the forwarded client address is used
|
||||||
used instead. That address becomes a bucket by family: IPv4 keys on
|
instead. That address becomes a bucket by family: IPv4 keys on the full
|
||||||
the full address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
||||||
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
||||||
let one subscriber rotate source addresses and mint a fresh bucket per
|
let one subscriber rotate source addresses and mint a fresh bucket per
|
||||||
request, evading these limits at the network layer without spoofing
|
request, evading these limits at the network layer without spoofing
|
||||||
anything; the cost is that distinct clients inside one `/64` share a
|
anything; the cost is that distinct clients inside one `/64` share a
|
||||||
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
||||||
they carry. See [Trusted proxies](#trusted-proxies). When that variable
|
they carry. See [Trusted proxies](#trusted-proxies). Deployed without that
|
||||||
does not cover the reverse proxy, a client behind it shares one bucket
|
variable set, a client behind a reverse proxy shares one bucket with
|
||||||
with every other client behind the same proxy. Set `TRUSTED_PROXIES` to
|
every other client behind the same proxy. Set `TRUSTED_PROXIES` to the
|
||||||
the proxy's address to get per-client limits back. What the shared bucket
|
proxy's address to get per-client limits back. What the shared bucket
|
||||||
costs is not the same for every limiter, and the two cases pull in
|
costs is not the same for every limiter, and the two cases pull in
|
||||||
opposite directions:
|
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 `TRUSTED_PROXIES` unset behind
|
||||||
cover the reverse proxy a production deployment is required to run
|
the reverse proxy a production deployment is required to run behind,
|
||||||
behind, every request keys on the proxy, so the aggregate limit
|
every request keys on the proxy, so the aggregate limit becomes a
|
||||||
becomes a service-wide ceiling of 1200 requests per minute across all
|
service-wide ceiling of 1200 requests per minute across all senders
|
||||||
senders and all entrypoints, where the per-entrypoint limit's
|
and all entrypoints, where the per-entrypoint limit's capacity still
|
||||||
capacity still grows with the number of entrypoints. Any deployment
|
grows with the number of entrypoints. Any deployment with more than a
|
||||||
with more than a handful of busy entrypoints must make sure
|
handful of busy entrypoints must set `TRUSTED_PROXIES`.
|
||||||
`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. Production deployments
|
||||||
should still make sure `TRUSTED_PROXIES` covers their proxy.
|
should still set `TRUSTED_PROXIES`; webhooker warns at startup
|
||||||
|
whenever it is empty, in any environment.
|
||||||
|
|
||||||
#### The login endpoint
|
#### The login endpoint
|
||||||
|
|
||||||
The login `POST` is the one endpoint with no pre-emptive limiter in
|
The login `POST` is the one endpoint with no pre-emptive limiter in
|
||||||
front of it, and that is deliberate. A limiter that spends budget on
|
front of it, and that is deliberate. A limiter that spends budget on
|
||||||
arrival is a lockout wherever clients share one bucket, as they do
|
arrival is a lockout in this deployment shape: sharing one bucket, a
|
||||||
behind a reverse proxy that `TRUSTED_PROXIES` does not cover: a
|
|
||||||
stranger sending five POSTs a minute — about 0.08 requests per second,
|
stranger sending five POSTs a minute — about 0.08 requests per second,
|
||||||
from anywhere — keeps it permanently full, and the operator has no
|
from anywhere — keeps it permanently full, and the operator has no
|
||||||
second administrative path. So the handler inverts the order:
|
second administrative path. So the handler inverts the order:
|
||||||
@@ -2690,10 +2673,8 @@ re-fills both verification slots on its first two requests. The
|
|||||||
remedies are to block the source at the reverse proxy, or to
|
remedies are to block the source at the reverse proxy, or to
|
||||||
rate-limit `POST /pages/login` there — the one place a limit can be
|
rate-limit `POST /pages/login` there — the one place a limit can be
|
||||||
applied without reintroducing the lockout, because the proxy sees the
|
applied without reintroducing the lockout, because the proxy sees the
|
||||||
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
real client address. Setting `TRUSTED_PROXIES` does not stop the
|
||||||
The flood's source is in the proxy's access log: webhooker's own logs
|
saturation, but it makes the source visible in the failure logs.
|
||||||
record the proxy's address, not the client's (see
|
|
||||||
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
|
||||||
|
|
||||||
Finer-grained per-webhook rate limits (configured in the web UI and
|
Finer-grained per-webhook rate limits (configured in the web UI and
|
||||||
enforced in the webhook handler) can layer on top of this env-level
|
enforced in the webhook handler) can layer on top of this env-level
|
||||||
@@ -3050,9 +3031,10 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
It runs behind session auth, so only a client already holding a
|
It runs behind session auth, so only a client already holding a
|
||||||
valid session reaches it, and an operator throttled out of changing
|
valid session reaches it, and an operator throttled out of changing
|
||||||
a password can still log in. The bucket is per client IP only when
|
a password can still log in. The bucket is per client IP only when
|
||||||
`TRUSTED_PROXIES` covers the reverse proxy; otherwise every client
|
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
||||||
shares one bucket, which costs precision rather than availability
|
shares one bucket, which costs precision rather than availability
|
||||||
(see [Rate Limiting](#rate-limiting))
|
(see [Rate Limiting](#rate-limiting)). webhooker warns at startup
|
||||||
|
whenever `TRUSTED_PROXIES` is empty
|
||||||
- Prometheus metrics behind basic auth
|
- Prometheus metrics behind basic auth
|
||||||
- Static assets embedded in binary (no filesystem access needed at
|
- Static assets embedded in binary (no filesystem access needed at
|
||||||
runtime)
|
runtime)
|
||||||
|
|||||||
+60
-22
@@ -75,11 +75,6 @@ const (
|
|||||||
// internet-exposed endpoint.
|
// internet-exposed endpoint.
|
||||||
defaultReceiverRateLimit = 120
|
defaultReceiverRateLimit = 120
|
||||||
|
|
||||||
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the
|
|
||||||
// RFC 1918 private ranges, which a reverse proxy reaching the
|
|
||||||
// process over a Docker network or a private LAN connects from.
|
|
||||||
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
|
||||||
|
|
||||||
// maxPort is the highest valid TCP port number. The lower
|
// maxPort is the highest valid TCP port number. The lower
|
||||||
// bound (at least 1) is enforced by envPositiveInt.
|
// bound (at least 1) is enforced by envPositiveInt.
|
||||||
maxPort = 65535
|
maxPort = 65535
|
||||||
@@ -177,14 +172,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. It is empty unless
|
||||||
// is the RFC 1918 private ranges (defaultTrustedProxies).
|
// TRUSTED_PROXIES is set, and empty means no peer is
|
||||||
// Other peers' forwarded headers are ignored and they are
|
// trusted: forwarded headers are then ignored entirely and
|
||||||
// identified by the connection's own address. Under the
|
// clients are identified by the connection's own address.
|
||||||
// default any client with a private address, directly or
|
// Members can choose their own rate-limit key, so this must
|
||||||
// through a trusted proxy, can choose its own rate-limit
|
// name proxy hosts only, never a block that also covers
|
||||||
// key, so where any clients have private addresses this must
|
// clients.
|
||||||
// be set to the proxy hosts alone.
|
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// AllowedEgressCIDRs is the set of networks a delivery target
|
// AllowedEgressCIDRs is the set of networks a delivery target
|
||||||
@@ -466,15 +460,14 @@ func parseCIDR(entry string) (netip.Prefix, error) {
|
|||||||
|
|
||||||
// envPrefixList returns the value of the named environment variable
|
// envPrefixList returns the value of the named environment variable
|
||||||
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
||||||
// allowed). An unset, empty, or blank value is read as defaultValue
|
// allowed). An unset, empty, or blank value yields an empty list. A
|
||||||
// instead. A set value containing an unparseable entry is a hard
|
// set value containing an unparseable entry is a hard error naming
|
||||||
// error naming the key and the bad entry, so startup fails loudly
|
// the key and the bad entry, so startup fails loudly rather than
|
||||||
// rather than silently running with a list the operator did not
|
// silently running with a list the operator did not intend.
|
||||||
// intend.
|
func envPrefixList(key string) ([]netip.Prefix, error) {
|
||||||
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
|
|
||||||
v := strings.TrimSpace(os.Getenv(key))
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
if v == "" {
|
if v == "" {
|
||||||
v = defaultValue
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var prefixes []netip.Prefix
|
var prefixes []netip.Prefix
|
||||||
@@ -688,12 +681,12 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
|
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
|
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -767,6 +760,50 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnSharedRateLimitBucket logs a startup warning whenever
|
||||||
|
// TRUSTED_PROXIES is empty, in any environment.
|
||||||
|
//
|
||||||
|
// With no trusted proxies every rate limiter keys on the connecting
|
||||||
|
// peer's address. Whether that is harmless or dangerous depends on
|
||||||
|
// what is in front of the process, which this code cannot observe:
|
||||||
|
// with nothing in front, the peer is the client and the limits are
|
||||||
|
// per-client as intended; behind a reverse proxy the peer is the proxy
|
||||||
|
// for every request, so all clients share one bucket per limiter.
|
||||||
|
//
|
||||||
|
// The login endpoint no longer spends budget on arrival — it verifies
|
||||||
|
// credentials first and charges only failures — so a shared bucket
|
||||||
|
// cannot deny the operator a correct password. What it does collapse
|
||||||
|
// is the failure counting: one client's wrong passwords throttle
|
||||||
|
// everyone else's wrong passwords, and the receiver's limits become
|
||||||
|
// service-wide ceilings.
|
||||||
|
//
|
||||||
|
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
|
||||||
|
// behind a proxy every client shares one bucket in dev and prod alike.
|
||||||
|
//
|
||||||
|
// The default of trusting nobody is deliberate — trusting forwarded
|
||||||
|
// headers from arbitrary peers lets any client choose its own bucket —
|
||||||
|
// so this warns rather than failing startup or changing the key.
|
||||||
|
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
|
||||||
|
if len(c.TrustedProxies) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn(
|
||||||
|
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
|
||||||
|
"connecting peer's address. With nothing proxying to "+
|
||||||
|
"this process that is the client itself and the limits "+
|
||||||
|
"are per-client as intended. Behind a reverse proxy the "+
|
||||||
|
"peer is the proxy on every request, so all clients "+
|
||||||
|
"share one bucket per limit: the receiver limits become "+
|
||||||
|
"service-wide ceilings, and one client's failed logins "+
|
||||||
|
"throttle every other client's failed logins — a "+
|
||||||
|
"correct password still gets in. If anything proxies to "+
|
||||||
|
"this process, set TRUSTED_PROXIES to its address.",
|
||||||
|
"environment", c.Environment,
|
||||||
|
"trustedProxies", len(c.TrustedProxies),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a Config by reading environment variables.
|
// New creates a Config by reading environment variables.
|
||||||
//
|
//
|
||||||
//nolint:revive // lc parameter is required by fx even if unused.
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
@@ -812,6 +849,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
s.warnSharedRateLimitBucket(log)
|
||||||
s.warnEgressAllowlist(log)
|
s.warnEgressAllowlist(log)
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
+101
-14
@@ -551,11 +551,6 @@ func testReceiverRateLimitSuccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTrustedProxies(t *testing.T) {
|
func TestTrustedProxies(t *testing.T) {
|
||||||
// Unset, the RFC 1918 private ranges are trusted, so a reverse
|
|
||||||
// proxy on a Docker network or a private LAN is covered without
|
|
||||||
// configuration.
|
|
||||||
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
@@ -564,21 +559,18 @@ func TestTrustedProxies(t *testing.T) {
|
|||||||
expected []string
|
expected []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
// The default must be "trust nobody": an empty list
|
||||||
|
// means forwarded headers are ignored, never that
|
||||||
|
// every peer may speak for the client.
|
||||||
name: caseUnsetUsesDefault,
|
name: caseUnsetUsesDefault,
|
||||||
set: false,
|
set: false,
|
||||||
expected: defaultProxies,
|
expected: []string{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "blank value uses default",
|
name: "blank value trusts nothing",
|
||||||
set: true,
|
set: true,
|
||||||
value: " ",
|
value: " ",
|
||||||
expected: defaultProxies,
|
expected: []string{},
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "set value replaces the default entirely",
|
|
||||||
set: true,
|
|
||||||
value: "203.0.113.7",
|
|
||||||
expected: []string{"203.0.113.7/32"},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: caseValidValueParsed,
|
name: caseValidValueParsed,
|
||||||
@@ -853,6 +845,101 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSharedRateLimitBucketWarning covers the startup warning that
|
||||||
|
// tells an operator a deployment behind a reverse proxy shares one
|
||||||
|
// rate-limit bucket between every client, which turns the receiver
|
||||||
|
// limits into service-wide ceilings and collapses login failure
|
||||||
|
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
|
||||||
|
// environment, because behind a proxy every client shares one bucket
|
||||||
|
// in dev and prod alike. It stays quiet once proxies are named.
|
||||||
|
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
environment string
|
||||||
|
trustedProxies string
|
||||||
|
expectWarning bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prod without trusted proxies warns",
|
||||||
|
environment: config.EnvironmentProd,
|
||||||
|
expectWarning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prod with trusted proxies is quiet",
|
||||||
|
environment: config.EnvironmentProd,
|
||||||
|
trustedProxies: cidrPrivateV4,
|
||||||
|
expectWarning: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dev without trusted proxies warns",
|
||||||
|
environment: config.EnvironmentDev,
|
||||||
|
expectWarning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dev with trusted proxies is quiet",
|
||||||
|
environment: config.EnvironmentDev,
|
||||||
|
trustedProxies: cidrPrivateV4,
|
||||||
|
expectWarning: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
|
||||||
|
|
||||||
|
if tt.trustedProxies == "" {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("TRUSTED_PROXIES"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
log := slog.New(slog.NewJSONHandler(
|
||||||
|
&buf, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
config.WarnSharedRateLimitBucketForTest(log),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !tt.expectWarning {
|
||||||
|
assert.Empty(t, buf.String())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, logged, `"level":"WARN"`)
|
||||||
|
assert.Contains(t, logged, "TRUSTED_PROXIES")
|
||||||
|
assert.Contains(t, logged, "share one bucket")
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "throttle every other client's failed logins",
|
||||||
|
)
|
||||||
|
// The warning must not claim a lockout the login
|
||||||
|
// endpoint no longer permits: credentials are verified
|
||||||
|
// before any budget is spent.
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "a correct password still gets in",
|
||||||
|
)
|
||||||
|
// The text must stay accurate for a developer with
|
||||||
|
// nothing in front of the process, where an empty
|
||||||
|
// list costs nothing.
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "nothing proxying to this process",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// metricsEnv describes what one subtest below puts in the
|
// metricsEnv describes what one subtest below puts in the
|
||||||
// environment for a single METRICS_ variable. A variable that is
|
// environment for a single METRICS_ variable. A variable that is
|
||||||
// set to the empty string and one that is not set at all are
|
// set to the empty string and one that is not set at all are
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ import "log/slog"
|
|||||||
// the external config_test package so each helper can be covered by
|
// the external config_test package so each helper can be covered by
|
||||||
// its own table-driven test without weakening the package API.
|
// its own table-driven test without weakening the package API.
|
||||||
|
|
||||||
|
// WarnSharedRateLimitBucketForTest loads a Config from the current
|
||||||
|
// environment and emits its startup warnings to log. The real logger
|
||||||
|
// writes to stdout, so this lets the warning's firing condition be
|
||||||
|
// asserted against a handler the test controls.
|
||||||
|
func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
|
||||||
|
c, err := loadFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.warnSharedRateLimitBucket(log)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// WarnEgressAllowlistForTest loads a Config from the current
|
// WarnEgressAllowlistForTest loads a Config from the current
|
||||||
// environment and emits its egress-allowlist startup warning to
|
// environment and emits its egress-allowlist startup warning to
|
||||||
// log, so a test can assert both that the warning fires only when
|
// log, so a test can assert both that the warning fires only when
|
||||||
|
|||||||
@@ -79,3 +79,9 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
|||||||
func DummyPasswordHashForTest() string {
|
func DummyPasswordHashForTest() string {
|
||||||
return dummyPasswordHash()
|
return dummyPasswordHash()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashPasswordWithDefaultsForTest hashes with the shipped Argon2id
|
||||||
|
// parameters, which TestMain has lowered for HashPassword itself.
|
||||||
|
func HashPasswordWithDefaultsForTest(password string) (string, error) {
|
||||||
|
return hashPasswordWith(password, DefaultPasswordConfig())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain lowers the password hashing cost before any test runs. See
|
||||||
|
// database.LowerPasswordHashCostForTest.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
database.LowerPasswordHashCostForTest()
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
@@ -63,10 +63,24 @@ func DefaultPasswordConfig() *PasswordConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashConfig is what HashPassword hashes with: the defaults above.
|
||||||
|
// Only a test binary changes it, through LowerPasswordHashCostForTest,
|
||||||
|
// before any test runs.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // see above
|
||||||
|
var hashConfig = DefaultPasswordConfig()
|
||||||
|
|
||||||
// HashPassword generates an Argon2id hash of the password
|
// HashPassword generates an Argon2id hash of the password
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
config := DefaultPasswordConfig()
|
return hashPasswordWith(password, hashConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashPasswordWith generates an Argon2id hash of the password with
|
||||||
|
// the given parameters.
|
||||||
|
func hashPasswordWith(
|
||||||
|
password string,
|
||||||
|
config *PasswordConfig,
|
||||||
|
) (string, error) {
|
||||||
// Generate a salt
|
// Generate a salt
|
||||||
salt := make([]byte, config.SaltLen)
|
salt := make([]byte, config.SaltLen)
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,36 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHashPassword_ShippedParameters hashes and verifies at the
|
||||||
|
// shipped Argon2id parameters. Every other test hashes at the lowered
|
||||||
|
// memory cost TestMain sets, so this is the one that keeps production
|
||||||
|
// hashing covered. One hash and one verification: each costs 64 MB.
|
||||||
|
func TestHashPassword_ShippedParameters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
password := "correct horse battery staple"
|
||||||
|
|
||||||
|
hash, err := database.HashPasswordWithDefaultsForTest(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hashing with the shipped parameters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
|
||||||
|
|
||||||
|
if !strings.HasPrefix(hash, shipped) {
|
||||||
|
t.Errorf("hash = %q, want prefix %q", hash, shipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := database.VerifyPassword(password, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyPassword() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
t.Error("VerifyPassword() returned false for correct password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||||
// path. Login charges an unknown username a verification against a
|
// path. Login charges an unknown username a verification against a
|
||||||
// dummy hash so that a nonexistent account is not answered in
|
// dummy hash so that a nonexistent account is not answered in
|
||||||
|
|||||||
@@ -45,3 +45,20 @@ func NewTestWebhookDBManagerWithLogger(
|
|||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testArgon2Memory is the Argon2id memory cost, in KiB, that test
|
||||||
|
// binaries hash with: 1 MB instead of the shipped 64 MB.
|
||||||
|
const testArgon2Memory = 1024
|
||||||
|
|
||||||
|
// LowerPasswordHashCostForTest makes HashPassword use a 1 MB Argon2id
|
||||||
|
// memory cost instead of the shipped 64 MB, for the rest of the
|
||||||
|
// process. Call it from TestMain, before any test runs.
|
||||||
|
//
|
||||||
|
// Every test that starts a database hashes the bootstrap admin
|
||||||
|
// password, and a package runs dozens of those tests in parallel.
|
||||||
|
// Under the race detector each 64 MB hash holds about 150 MB resident.
|
||||||
|
// VerifyPassword reads the cost from the hash it checks, so
|
||||||
|
// verification follows. Production code never calls this.
|
||||||
|
func LowerPasswordHashCostForTest() {
|
||||||
|
hashConfig.Memory = testArgon2Memory
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package gormlog_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain lowers the password hashing cost before any test runs. See
|
||||||
|
// database.LowerPasswordHashCostForTest.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
database.LowerPasswordHashCostForTest()
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
@@ -103,10 +103,9 @@ func (h *Handlers) renderLoginError(
|
|||||||
// The credential check runs BEFORE any rate-limit budget is
|
// The credential check runs BEFORE any rate-limit budget is
|
||||||
// consulted, and only a failed check spends budget. That is what
|
// consulted, and only a failed check spends budget. That is what
|
||||||
// keeps the single administrative path reachable: behind the reverse
|
// keeps the single administrative path reachable: behind the reverse
|
||||||
// proxy this deployment requires, when TRUSTED_PROXIES does not cover
|
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
|
||||||
// it, every client shares one bucket, so a limiter spent on arrival
|
// client shares one bucket, so a limiter spent on arrival lets any
|
||||||
// lets any stranger deny the operator's own correct password
|
// stranger deny the operator's own correct password indefinitely.
|
||||||
// indefinitely.
|
|
||||||
//
|
//
|
||||||
// Verifying first means every login POST costs an Argon2id hash, so
|
// Verifying first means every login POST costs an Argon2id hash, so
|
||||||
// the work is taken under a bounded number of verification slots.
|
// the work is taken under a bounded number of verification slots.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
|
|
||||||
// sharedProxyPeer is the whole point of this file. Production is
|
// sharedProxyPeer is the whole point of this file. Production is
|
||||||
// required to run behind a TLS-terminating reverse proxy, and
|
// required to run behind a TLS-terminating reverse proxy, and
|
||||||
// when TRUSTED_PROXIES does not cover it every client — attacker
|
// TRUSTED_PROXIES defaults to empty, so every client — attacker
|
||||||
// and operator alike — reaches the process from the proxy's
|
// and operator alike — reaches the process from the proxy's
|
||||||
// address and shares one rate-limit bucket. Both parties in
|
// address and shares one rate-limit bucket. Both parties in
|
||||||
// these tests therefore use the same RemoteAddr.
|
// these tests therefore use the same RemoteAddr.
|
||||||
@@ -115,11 +115,11 @@ func floodFailures(
|
|||||||
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
||||||
//
|
//
|
||||||
// The attacker and the operator share one rate-limit bucket, because
|
// The attacker and the operator share one rate-limit bucket, because
|
||||||
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not
|
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every
|
||||||
// cover it, every client keys on the proxy's address. The attacker
|
// client keys on the proxy's address. The attacker floods the
|
||||||
// floods the operator's own username — a single-admin product has a
|
// operator's own username — a single-admin product has a predictable
|
||||||
// predictable one — far past the failure limit. The operator must
|
// one — far past the failure limit. The operator must still be able
|
||||||
// still be able to log in with the correct password.
|
// to log in with the correct password.
|
||||||
//
|
//
|
||||||
// This fails if credentials stop being verified ahead of the limiter.
|
// This fails if credentials stop being verified ahead of the limiter.
|
||||||
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain lowers the password hashing cost before any test runs. See
|
||||||
|
// database.LowerPasswordHashCostForTest.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
database.LowerPasswordHashCostForTest()
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
@@ -108,10 +108,10 @@ type failureWindow struct {
|
|||||||
//
|
//
|
||||||
// A limiter that spends budget on arrival cannot protect a
|
// A limiter that spends budget on arrival cannot protect a
|
||||||
// single-admin product: behind the reverse proxy the deployment
|
// single-admin product: behind the reverse proxy the deployment
|
||||||
// requires, when TRUSTED_PROXIES does not cover it, every client
|
// requires, with TRUSTED_PROXIES unset, every client keys on the
|
||||||
// keys on the proxy, so a stranger trickling five POSTs a minute
|
// proxy, so a stranger trickling five POSTs a minute keeps the one
|
||||||
// keeps the one bucket full and the operator's own correct password
|
// bucket full and the operator's own correct password is answered 429
|
||||||
// is answered 429 forever. There is no second administrative path.
|
// forever. There is no second administrative path.
|
||||||
//
|
//
|
||||||
// So budget is spent only by a FAILED verification. A correct
|
// So budget is spent only by a FAILED verification. A correct
|
||||||
// password is never throttled, whatever the counters say, which is
|
// password is never throttled, whatever the counters say, which is
|
||||||
|
|||||||
@@ -123,8 +123,9 @@ func bucketKey(addr netip.Addr) string {
|
|||||||
return prefix.String()
|
return prefix.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTrustedProxy reports whether addr belongs to a network in
|
// isTrustedProxy reports whether addr belongs to a network the
|
||||||
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
// operator listed in TRUSTED_PROXIES. The list is empty by default,
|
||||||
|
// so by default nothing is trusted.
|
||||||
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
||||||
for _, prefix := range m.params.Config.TrustedProxies {
|
for _, prefix := range m.params.Config.TrustedProxies {
|
||||||
if prefix.Contains(addr) {
|
if prefix.Contains(addr) {
|
||||||
|
|||||||
@@ -384,8 +384,8 @@ const (
|
|||||||
// trustedProxyCIDR is the proxy network the forwarded-path
|
// trustedProxyCIDR is the proxy network the forwarded-path
|
||||||
// tests configure, and trustedPeer an address inside it. A
|
// tests configure, and trustedPeer an address inside it. A
|
||||||
// production deployment is required to run behind a reverse
|
// production deployment is required to run behind a reverse
|
||||||
// proxy that TRUSTED_PROXIES covers, either by the default or by
|
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
||||||
// a set value, so this is the shape the bucketing has to hold in.
|
// bucketing has to hold in.
|
||||||
trustedProxyCIDR = "10.0.0.0/8"
|
trustedProxyCIDR = "10.0.0.0/8"
|
||||||
trustedPeer = "10.0.0.1:44444"
|
trustedPeer = "10.0.0.1:44444"
|
||||||
)
|
)
|
||||||
@@ -426,8 +426,8 @@ func assertSharedBucket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
||||||
// this gating exists for: from a peer that is not a trusted
|
// this gating exists for: with no trusted proxies configured (the
|
||||||
// proxy, a client that rotates a forwarded header on every
|
// default), a client that rotates a forwarded header on every
|
||||||
// request must stay in one bucket. If forwarded headers were
|
// request must stay in one bucket. If forwarded headers were
|
||||||
// trusted unconditionally, each spoofed value would mint a fresh
|
// trusted unconditionally, each spoofed value would mint a fresh
|
||||||
// bucket and the limit would stop no one.
|
// bucket and the limit would stop no one.
|
||||||
@@ -1097,9 +1097,8 @@ func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
|
|||||||
// that arrives from trustedPeer — a configured trusted proxy — and
|
// that arrives from trustedPeer — a configured trusted proxy — and
|
||||||
// names forwarded as its client in X-Forwarded-For. That is the
|
// names forwarded as its client in X-Forwarded-For. That is the
|
||||||
// production path: a deployment is required to run behind a reverse
|
// production path: a deployment is required to run behind a reverse
|
||||||
// proxy that TRUSTED_PROXIES covers, either by the default or by a
|
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
||||||
// set value, so the forwarded address, not the peer, is what the
|
// peer, is what the limiters bucket on there.
|
||||||
// limiters bucket on there.
|
|
||||||
func forwardedKeyFor(
|
func forwardedKeyFor(
|
||||||
t *testing.T, m *middleware.Middleware, forwarded string,
|
t *testing.T, m *middleware.Middleware, forwarded string,
|
||||||
) string {
|
) string {
|
||||||
@@ -1179,9 +1178,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// Every existing test of this fallback uses an IPv4 proxy, where
|
// Every existing test of this fallback uses an IPv4 proxy, where
|
||||||
// bucketKey is the identity function, so replacing the call with
|
// bucketKey is the identity function, so replacing the call with
|
||||||
// peer.String() leaves the whole suite green. Only addresses inside
|
// peer.String() leaves the whole suite green. Only operator-listed
|
||||||
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so
|
// addresses reach this line and the fallback is fail-closed, so this
|
||||||
// this pins behaviour rather than fixing a defect.
|
// pins behaviour rather than fixing a defect.
|
||||||
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package resetpw_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain lowers the password hashing cost before any test runs. See
|
||||||
|
// database.LowerPasswordHashCostForTest.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
database.LowerPasswordHashCostForTest()
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
|||||||
// and the database, exactly as internal/handlers builds them.
|
// and the database, exactly as internal/handlers builds them.
|
||||||
//
|
//
|
||||||
// One application per test function, not per case: every start that
|
// One application per test function, not per case: every start that
|
||||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
// finds no account seeds one with an Argon2id hash, and this package's
|
||||||
// budget is not the place to spend that repeatedly.
|
// budget is not the place to spend that repeatedly.
|
||||||
func newServerApp(
|
func newServerApp(
|
||||||
t *testing.T, dir string,
|
t *testing.T, dir string,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain lowers the password hashing cost before any test runs. See
|
||||||
|
// database.LowerPasswordHashCostForTest.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
database.LowerPasswordHashCostForTest()
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
@@ -154,12 +154,11 @@ func (s *Server) setupPageRoutes() {
|
|||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
|
|
||||||
// The login POST carries no pre-emptive rate limiter. Behind
|
// The login POST carries no pre-emptive rate limiter. Behind
|
||||||
// the reverse proxy production requires, when TRUSTED_PROXIES
|
// the reverse proxy production requires, with TRUSTED_PROXIES
|
||||||
// does not cover it, every client shares one bucket, so a
|
// unset, every client shares one bucket, so a limiter spent
|
||||||
// limiter spent on arrival lets any stranger deny the operator
|
// on arrival lets any stranger deny the operator the only
|
||||||
// the only administrative path. The handler verifies
|
// administrative path. The handler verifies credentials first
|
||||||
// credentials first and charges only failures; see
|
// and charges only failures; see Handlers.authenticateUser.
|
||||||
// Handlers.authenticateUser.
|
|
||||||
r.Get("/login", s.h.HandleLoginPage())
|
r.Get("/login", s.h.HandleLoginPage())
|
||||||
r.Post("/login", s.h.HandleLoginSubmit())
|
r.Post("/login", s.h.HandleLoginSubmit())
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -22,13 +22,18 @@
|
|||||||
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
||||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||||
# 67s, that is the datum to revisit the org figure with.
|
# 67s, that is the datum to revisit the org figure with.
|
||||||
|
#
|
||||||
|
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
||||||
|
# binaries build or run at once, each with at most eight parallel tests. Under
|
||||||
|
# -race every test binary and every link costs a few hundred MB, so the
|
||||||
|
# defaults (one per core) add up to several GB on a many-core host.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
go test -v -race -timeout 90s ./...
|
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user