Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51186b347b | ||
|
|
8ad2a86e4b | ||
|
|
a891b726e5 | ||
|
|
ab63b5f777 | ||
|
|
9cf9cdd8eb |
@@ -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` |
|
||||
| `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. A set value replaces the default. Under the default, any client with a private address, whether it connects directly or through the 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) |
|
||||
|
||||
#### Allowing egress to your own network
|
||||
@@ -379,48 +379,41 @@ unlocked.
|
||||
`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
|
||||
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
|
||||
the addresses of your reverse proxies.
|
||||
`X-Forwarded-For` header the rate limiters believe, so it should name
|
||||
the addresses of your reverse proxies and nothing else.
|
||||
|
||||
`X-Forwarded-For` is honoured **only** when the connecting peer 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
|
||||
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
|
||||
reaching webhooker over a Docker network or a private LAN without
|
||||
anything set. A set value replaces the default entirely. A set but
|
||||
unparseable value aborts startup.
|
||||
the connection's own address and the header is ignored. The default is
|
||||
the empty list, which trusts nobody — anything else would let any
|
||||
client pick its own rate limit bucket, minting a fresh one per request
|
||||
or draining someone else's. Set it to the address of your reverse
|
||||
proxy, and to nothing wider. A set but unparseable value aborts
|
||||
startup.
|
||||
|
||||
Trusting those ranges has two consequences for clients with private
|
||||
addresses:
|
||||
|
||||
- Any such client, whether it connects directly or through the proxy,
|
||||
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
|
||||
That default is safe against forged headers, but leaving it unset in
|
||||
production has a cost you must know about. Production runs behind a
|
||||
TLS-terminating reverse proxy, so with `TRUSTED_PROXIES` unset every
|
||||
request 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.
|
||||
passwords.
|
||||
|
||||
What it cannot do is lock the operator out. The login endpoint
|
||||
verifies credentials **before** it consults any limit and charges only
|
||||
failures, so a correct password is never throttled no matter how full
|
||||
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.
|
||||
Reverse proxies append to `X-Forwarded-For` but forward other client
|
||||
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`,
|
||||
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`
|
||||
- List proxy hosts **only**. 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 all three
|
||||
limits, including the unauthenticated webhook receiver, silently
|
||||
bypassable by every client in the block.
|
||||
client's bucket. Never list a block that also covers clients — a
|
||||
broad `10.0.0.0/8` on a network where clients live in the same range
|
||||
makes all three limits, including the unauthenticated webhook
|
||||
receiver, silently bypassable by every client in the block.
|
||||
|
||||
#### Sessions
|
||||
|
||||
@@ -771,16 +764,10 @@ repository's `Dockerfile` and runs it. The app needs:
|
||||
|
||||
- **Environment variables:**
|
||||
- `WEBHOOKER_ENVIRONMENT=prod`
|
||||
- `TRUSTED_PROXIES`: Docker networks use private addresses, so the
|
||||
default covers your reverse proxy on that network. Under the
|
||||
default, any client with a private address, whether it connects
|
||||
directly or through the proxy, can choose its own rate-limit key
|
||||
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).
|
||||
- `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
|
||||
network. 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
|
||||
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
|
||||
`/var/lib/webhooker`.
|
||||
@@ -843,14 +830,12 @@ reports.
|
||||
behind a proxy means the `X-Forwarded-Proto` header. The block below
|
||||
sets it; without it every request is read as plaintext and cookies
|
||||
ship without `Secure`. See [Configuration](#configuration).
|
||||
3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** Unset,
|
||||
it covers the RFC 1918 private ranges, so a proxy on a Docker
|
||||
network or a private LAN is covered and one on loopback is not. For
|
||||
a proxy it does not cover, every rate limiter keys on the connecting
|
||||
peer, which is the proxy on every request: all clients collapse into
|
||||
one global bucket per limit and the receiver's per-IP limits become
|
||||
service-wide ceilings. See [Trusted proxies](#trusted-proxies). If
|
||||
any clients have private addresses, list the proxy and nothing else.
|
||||
3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
|
||||
limiter keys on the connecting peer, which behind a proxy is the
|
||||
proxy on every request: all clients collapse into one global bucket
|
||||
per limit and the receiver's per-IP limits become service-wide
|
||||
ceilings. See [Trusted proxies](#trusted-proxies). List the proxy
|
||||
and nothing else.
|
||||
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
||||
port. webhooker's Origin/Referer check compares against the host it
|
||||
was given, so on any port other than 443 `$host` makes every form
|
||||
@@ -1411,11 +1396,10 @@ It uses:
|
||||
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
||||
sliding-window rate limiting of the password-change and webhook
|
||||
receiver endpoints. The bucket is per client IP only when
|
||||
`TRUSTED_PROXIES` covers the reverse proxy (by default it covers the
|
||||
RFC 1918 private ranges); otherwise every client behind that proxy
|
||||
shares one bucket per limit. The login endpoint counts failed
|
||||
attempts itself instead, so that a correct password is never
|
||||
throttled (see [Rate Limiting](#rate-limiting))
|
||||
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
||||
behind that proxy shares one bucket per limit. The login endpoint
|
||||
counts failed attempts itself instead, so that a correct password is
|
||||
never throttled (see [Rate Limiting](#rate-limiting))
|
||||
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||
`/metrics` behind basic auth
|
||||
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||
@@ -2596,30 +2580,30 @@ let one subscriber rotate source addresses and mint a fresh bucket per
|
||||
request, evading these limits at the network layer without spoofing
|
||||
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
|
||||
they carry. See [Trusted proxies](#trusted-proxies). When that variable
|
||||
does not cover the reverse proxy, a client behind it shares one bucket
|
||||
with every other client behind the same proxy. Set `TRUSTED_PROXIES` to
|
||||
the proxy's address to get per-client limits back. What the shared bucket
|
||||
they carry. See [Trusted proxies](#trusted-proxies). Deployed without that
|
||||
variable set, a client behind a reverse proxy shares one bucket with
|
||||
every other client behind the same proxy. Set `TRUSTED_PROXIES` to the
|
||||
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
|
||||
opposite directions:
|
||||
|
||||
- For the **receiver** limits it costs throughput, which is the safe
|
||||
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
|
||||
than for the per-entrypoint one: when `TRUSTED_PROXIES` does not
|
||||
cover the reverse proxy a production deployment is required to run
|
||||
behind, every request keys on the proxy, so the aggregate limit
|
||||
becomes a service-wide ceiling of 1200 requests per minute across all
|
||||
senders and all entrypoints, where the per-entrypoint limit's
|
||||
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.
|
||||
than for the per-entrypoint one: with `TRUSTED_PROXIES` unset behind
|
||||
the reverse proxy a production deployment is required to run behind,
|
||||
every request keys on the proxy, so the aggregate limit becomes a
|
||||
service-wide ceiling of 1200 requests per minute across all senders
|
||||
and all entrypoints, where the per-entrypoint limit's capacity still
|
||||
grows with the number of entrypoints. Any deployment with more than a
|
||||
handful of busy entrypoints must set `TRUSTED_PROXIES`.
|
||||
- For the **login and password-change** limits it costs precision, not
|
||||
availability. Login failures from every client land in one counter,
|
||||
so a stranger's wrong passwords make the operator's own wrong
|
||||
passwords answer `429` sooner; the operator's _correct_ password is
|
||||
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
|
||||
|
||||
@@ -2722,10 +2706,8 @@ re-fills both verification slots on its first two requests. The
|
||||
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
|
||||
applied without reintroducing the lockout, because the proxy sees the
|
||||
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
||||
The flood's source is in the proxy's access log: webhooker's own logs
|
||||
record the proxy's address, not the client's (see
|
||||
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
||||
real client address. Setting `TRUSTED_PROXIES` does not stop the
|
||||
saturation, but it makes the source visible in the failure logs.
|
||||
|
||||
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
|
||||
@@ -2739,7 +2721,7 @@ abuse limit later; they are tracked as future work.
|
||||
| ------ | --------------------------- | ----------- |
|
||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) |
|
||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
||||
| any | `/s/*` | Static file serving (embedded CSS, JS). Mounted for every method, not just `GET`/`HEAD`: chi's `Mount` registers all methods and `http.FileServer` special-cases only `HEAD` (by omitting the body), so a `POST` or `DELETE` to an asset is answered `200` with the file. Pinned by `TestStaticServesEveryMethod` |
|
||||
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
||||
| `POST` | `/webhook/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
||||
|
||||
#### Authentication Endpoints
|
||||
@@ -3082,9 +3064,10 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
It runs behind session auth, so only a client already holding a
|
||||
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
|
||||
`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
|
||||
(see [Rate Limiting](#rate-limiting))
|
||||
(see [Rate Limiting](#rate-limiting)). webhooker warns at startup
|
||||
whenever `TRUSTED_PROXIES` is empty
|
||||
- Prometheus metrics behind basic auth
|
||||
- Static assets embedded in binary (no filesystem access needed at
|
||||
runtime)
|
||||
@@ -3297,3 +3280,5 @@ MIT
|
||||
## Author
|
||||
|
||||
[@sneak](https://sneak.berlin)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/getsentry/sentry-go v0.25.0
|
||||
github.com/go-chi/chi v1.5.5
|
||||
github.com/go-chi/cors v1.2.1
|
||||
@@ -29,7 +30,6 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
|
||||
+60
-22
@@ -75,11 +75,6 @@ const (
|
||||
// internet-exposed endpoint.
|
||||
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
|
||||
// bound (at least 1) is enforced by envPositiveInt.
|
||||
maxPort = 65535
|
||||
@@ -177,14 +172,13 @@ type Config struct {
|
||||
|
||||
// TrustedProxies is the set of networks whose members are
|
||||
// allowed to speak for the client with X-Forwarded-For, the
|
||||
// only forwarded header read. Unless TRUSTED_PROXIES is set it
|
||||
// is the RFC 1918 private ranges (defaultTrustedProxies).
|
||||
// Other peers' forwarded headers are ignored and they are
|
||||
// identified by the connection's own address. Under the
|
||||
// default any client with a private address, directly or
|
||||
// through a proxy, can choose its own rate-limit key, so
|
||||
// where any clients have private addresses this must be set
|
||||
// to the proxy hosts alone.
|
||||
// only forwarded header read. It is empty unless
|
||||
// TRUSTED_PROXIES is set, and empty means no peer is
|
||||
// trusted: forwarded headers are then ignored entirely and
|
||||
// clients are identified by the connection's own address.
|
||||
// Members can choose their own rate-limit key, so this must
|
||||
// name proxy hosts only, never a block that also covers
|
||||
// clients.
|
||||
TrustedProxies []netip.Prefix
|
||||
|
||||
// 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
|
||||
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
||||
// allowed). An unset, empty, or blank value is read as defaultValue
|
||||
// instead. A set value containing an unparseable entry is a hard
|
||||
// error naming the key and the bad entry, so startup fails loudly
|
||||
// rather than silently running with a list the operator did not
|
||||
// intend.
|
||||
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
|
||||
// allowed). An unset, empty, or blank value yields an empty list. A
|
||||
// set value containing an unparseable entry is a hard error naming
|
||||
// the key and the bad entry, so startup fails loudly rather than
|
||||
// silently running with a list the operator did not intend.
|
||||
func envPrefixList(key string) ([]netip.Prefix, error) {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
v = defaultValue
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var prefixes []netip.Prefix
|
||||
@@ -688,12 +681,12 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
|
||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
|
||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
||||
if err != nil {
|
||||
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.
|
||||
//
|
||||
//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(),
|
||||
)
|
||||
|
||||
s.warnSharedRateLimitBucket(log)
|
||||
s.warnEgressAllowlist(log)
|
||||
|
||||
return s, nil
|
||||
|
||||
+101
-14
@@ -551,11 +551,6 @@ func testReceiverRateLimitSuccess(
|
||||
}
|
||||
|
||||
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 {
|
||||
name string
|
||||
set bool
|
||||
@@ -564,21 +559,18 @@ func TestTrustedProxies(t *testing.T) {
|
||||
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,
|
||||
set: false,
|
||||
expected: defaultProxies,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "blank value uses default",
|
||||
name: "blank value trusts nothing",
|
||||
set: true,
|
||||
value: " ",
|
||||
expected: defaultProxies,
|
||||
},
|
||||
{
|
||||
name: "set value replaces the default entirely",
|
||||
set: true,
|
||||
value: "203.0.113.7",
|
||||
expected: []string{"203.0.113.7/32"},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
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
|
||||
// environment for a single METRICS_ variable. A variable that is
|
||||
// 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
|
||||
// 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
|
||||
// environment and emits its egress-allowlist startup warning to
|
||||
// log, so a test can assert both that the warning fires only when
|
||||
|
||||
@@ -103,10 +103,9 @@ func (h *Handlers) renderLoginError(
|
||||
// The credential check runs BEFORE any rate-limit budget is
|
||||
// consulted, and only a failed check spends budget. That is what
|
||||
// keeps the single administrative path reachable: behind the reverse
|
||||
// proxy this deployment requires, when TRUSTED_PROXIES does not cover
|
||||
// it, every client shares one bucket, so a limiter spent on arrival
|
||||
// lets any stranger deny the operator's own correct password
|
||||
// indefinitely.
|
||||
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
|
||||
// client shares one bucket, so a limiter spent on arrival lets any
|
||||
// stranger deny the operator's own correct password indefinitely.
|
||||
//
|
||||
// Verifying first means every login POST costs an Argon2id hash, so
|
||||
// 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
|
||||
// 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
|
||||
// address and shares one rate-limit bucket. Both parties in
|
||||
// these tests therefore use the same RemoteAddr.
|
||||
@@ -115,11 +115,11 @@ func floodFailures(
|
||||
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
||||
//
|
||||
// The attacker and the operator share one rate-limit bucket, because
|
||||
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not
|
||||
// cover it, every client keys on the proxy's address. The attacker
|
||||
// floods the operator's own username — a single-admin product has a
|
||||
// predictable one — far past the failure limit. The operator must
|
||||
// still be able to log in with the correct password.
|
||||
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every
|
||||
// client keys on the proxy's address. The attacker floods the
|
||||
// operator's own username — a single-admin product has a predictable
|
||||
// one — far past the failure limit. The operator must still be able
|
||||
// to log in with the correct password.
|
||||
//
|
||||
// This fails if credentials stop being verified ahead of the limiter.
|
||||
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
@@ -25,7 +27,7 @@ const maxRenderedResponseBytes = 4096
|
||||
// cut, so an oversized stored response never becomes a Go
|
||||
// string at all.
|
||||
const deliveryResultColumns = "delivery_id, attempt_num, success, " +
|
||||
"status_code, error, duration, " +
|
||||
"status_code, error, duration, created_at, " +
|
||||
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
|
||||
"length(cast(response_body as blob)) AS response_bytes"
|
||||
|
||||
@@ -106,6 +108,10 @@ type deliveryResultRow struct {
|
||||
Duration int64
|
||||
ResponseBody []byte
|
||||
ResponseBytes int64
|
||||
|
||||
// CreatedAt is when the attempt's result was recorded, which
|
||||
// is when the attempt finished.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// view projects a loaded row for rendering, stripping the
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||
maxBodyShift = 20
|
||||
// recentEventLimit is the number of recent events to show.
|
||||
recentEventLimit = 20
|
||||
recentEventLimit = 50
|
||||
// paginationPerPage is the number of items per page.
|
||||
paginationPerPage = 25
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// recentEventColumns is the recent events list's projection. It
|
||||
// reads the body's size and never the body itself, for the reason
|
||||
// maxRenderedBodyBytes gives; the cast to blob makes length count
|
||||
// bytes rather than characters.
|
||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
||||
"resubmitted_from_id, length(cast(body as blob)) AS body_bytes"
|
||||
|
||||
// RecentEventView is one row of the recent events list on a
|
||||
// webhook's page.
|
||||
type RecentEventView struct {
|
||||
Method string
|
||||
ContentType string
|
||||
|
||||
// ResubmittedFromID names the event this one was copied from,
|
||||
// empty for an event that arrived on the receiver.
|
||||
ResubmittedFromID string
|
||||
|
||||
// Received is how long ago the event arrived, and ReceivedUTC
|
||||
// the full timestamp the page shows on hover.
|
||||
Received string
|
||||
ReceivedUTC string
|
||||
|
||||
// Size is the size of the stored body.
|
||||
Size string
|
||||
|
||||
// ProcessingTime is how long the event's slowest delivery
|
||||
// took; see processingTime.
|
||||
ProcessingTime string
|
||||
|
||||
// Status is what the webhook's HTTP target answered, and
|
||||
// StatusClass its colour; see targetStatus. Both are empty
|
||||
// unless the webhook has exactly one HTTP target.
|
||||
Status string
|
||||
StatusClass string
|
||||
}
|
||||
|
||||
// recentEventRow is one row of recentEventColumns.
|
||||
type recentEventRow struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
Method string
|
||||
ContentType string
|
||||
ResubmittedFromID *string
|
||||
BodyBytes uint64
|
||||
}
|
||||
|
||||
// singleHTTPTargetID returns the ID of the webhook's HTTP target
|
||||
// when it has exactly one, and "" when it has none or several.
|
||||
func singleHTTPTargetID(targets []database.Target) string {
|
||||
id := ""
|
||||
count := 0
|
||||
|
||||
for i := range targets {
|
||||
if targets[i].Type == database.TargetTypeHTTP {
|
||||
id = targets[i].ID
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// loadRecentEvents loads the webhook's recentEventLimit newest
|
||||
// events for its page, newest first. statusTargetID is the
|
||||
// webhook's only HTTP target, or "" when the list shows no status.
|
||||
func (h *Handlers) loadRecentEvents(
|
||||
webhookDB *gorm.DB, webhookID, statusTargetID string,
|
||||
) ([]RecentEventView, error) {
|
||||
var rows []recentEventRow
|
||||
|
||||
err := webhookDB.Model(&database.Event{}).
|
||||
Select(recentEventColumns).
|
||||
Where("webhook_id = ?", webhookID).
|
||||
Order("created_at DESC").
|
||||
Limit(recentEventLimit).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
eventIDs := make([]string, len(rows))
|
||||
for i := range rows {
|
||||
eventIDs[i] = rows[i].ID
|
||||
}
|
||||
|
||||
// Oldest first, so an event's last delivery to a target is its
|
||||
// newest: a replay adds a delivery rather than changing the
|
||||
// earlier one.
|
||||
var deliveries []database.Delivery
|
||||
|
||||
err = webhookDB.
|
||||
Select("id, event_id, target_id, status, created_at").
|
||||
Where("event_id IN ?", eventIDs).
|
||||
Order("created_at ASC").
|
||||
Find(&deliveries).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byEvent := make(map[string][]database.Delivery, len(rows))
|
||||
deliveryIDs := make([]string, len(deliveries))
|
||||
|
||||
for i := range deliveries {
|
||||
eventID := deliveries[i].EventID
|
||||
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
|
||||
deliveryIDs[i] = deliveries[i].ID
|
||||
}
|
||||
|
||||
attempts, err := h.loadDeliveryResults(webhookDB, deliveryIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]RecentEventView, len(rows))
|
||||
for i := range rows {
|
||||
views[i] = rows[i].view(
|
||||
byEvent[rows[i].ID], attempts, statusTargetID,
|
||||
)
|
||||
}
|
||||
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// view projects a loaded row for rendering. deliveries is the
|
||||
// event's deliveries, oldest first, and attempts their recorded
|
||||
// attempts keyed by delivery ID.
|
||||
func (r *recentEventRow) view(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]deliveryResultRow,
|
||||
statusTargetID string,
|
||||
) RecentEventView {
|
||||
v := RecentEventView{
|
||||
Method: r.Method,
|
||||
ContentType: r.ContentType,
|
||||
Received: humanize.Time(r.CreatedAt),
|
||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||
Size: humanize.Bytes(r.BodyBytes),
|
||||
ProcessingTime: processingTime(deliveries, attempts),
|
||||
}
|
||||
|
||||
if r.ResubmittedFromID != nil {
|
||||
v.ResubmittedFromID = *r.ResubmittedFromID
|
||||
}
|
||||
|
||||
if statusTargetID != "" {
|
||||
v.Status, v.StatusClass = targetStatus(
|
||||
deliveries, attempts, statusTargetID,
|
||||
)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// processingTime is how long the event's slowest delivery took,
|
||||
// from being queued to its last recorded attempt, time spent
|
||||
// waiting between retries included. A delivery is queued when its
|
||||
// event is received, or when an operator replays it, so a replay
|
||||
// is timed from the replay rather than from the event's arrival.
|
||||
// It is "in progress" while any delivery is pending or retrying,
|
||||
// and empty for an event with no deliveries.
|
||||
func processingTime(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]deliveryResultRow,
|
||||
) string {
|
||||
if len(deliveries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var slowest time.Duration
|
||||
|
||||
for i := range deliveries {
|
||||
if !deliveries[i].Status.Terminal() {
|
||||
return "in progress"
|
||||
}
|
||||
|
||||
tries := attempts[deliveries[i].ID]
|
||||
if len(tries) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
last := tries[len(tries)-1].CreatedAt
|
||||
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
|
||||
}
|
||||
|
||||
return slowest.Round(time.Millisecond).String()
|
||||
}
|
||||
|
||||
// targetStatus is what the target answered for the event, and the
|
||||
// colour to show it in: the HTTP status code of the last attempt of
|
||||
// the event's newest delivery to the target. Without a code it is
|
||||
// "no response" when that attempt failed before a response
|
||||
// arrived, the delivery's status ("pending") before any attempt,
|
||||
// and "not sent" when the event has no delivery to the target.
|
||||
func targetStatus(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]deliveryResultRow,
|
||||
targetID string,
|
||||
) (string, string) {
|
||||
newest := -1
|
||||
|
||||
for i := range deliveries {
|
||||
if deliveries[i].TargetID == targetID {
|
||||
newest = i
|
||||
}
|
||||
}
|
||||
|
||||
if newest < 0 {
|
||||
return "not sent", "text-gray-400"
|
||||
}
|
||||
|
||||
tries := attempts[deliveries[newest].ID]
|
||||
if len(tries) == 0 {
|
||||
return string(deliveries[newest].Status), "text-gray-400"
|
||||
}
|
||||
|
||||
code := tries[len(tries)-1].StatusCode
|
||||
|
||||
switch {
|
||||
case code == 0:
|
||||
return "no response", "text-red-600"
|
||||
case code >= http.StatusInternalServerError:
|
||||
return strconv.Itoa(code), "text-red-600"
|
||||
case code >= http.StatusBadRequest:
|
||||
return strconv.Itoa(code), "text-yellow-600"
|
||||
case code >= http.StatusMultipleChoices:
|
||||
return strconv.Itoa(code), "text-gray-500"
|
||||
case code >= http.StatusOK:
|
||||
return strconv.Itoa(code), "text-green-600"
|
||||
default:
|
||||
return strconv.Itoa(code), "text-gray-500"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// statusTitle marks the status column's cell in a recent events
|
||||
// row; it is absent from the page when the column is not shown.
|
||||
const statusTitle = `title="HTTP status from the HTTP target"`
|
||||
|
||||
// recentEventsFixture is one started app and a webhook whose
|
||||
// recent events list a test fills.
|
||||
type recentEventsFixture struct {
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
webhook *database.Webhook
|
||||
webhookDB *gorm.DB
|
||||
}
|
||||
|
||||
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
|
||||
t.Helper()
|
||||
|
||||
f := &recentEventsFixture{}
|
||||
|
||||
var dbMgr *database.WebhookDBManager
|
||||
|
||||
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
f.webhook = seedWebhook(t, f.db)
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
f.webhookDB = webhookDB
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *recentEventsFixture) render(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
||||
}
|
||||
|
||||
// event records an event received at receivedAt.
|
||||
func (f *recentEventsFixture) event(
|
||||
t *testing.T, contentType, body string, receivedAt time.Time,
|
||||
) *database.Event {
|
||||
t.Helper()
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: f.webhook.ID,
|
||||
Method: http.MethodPost,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}
|
||||
event.CreatedAt = receivedAt
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(event).Error)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// delivery records a delivery of the event to the target, queued
|
||||
// when the event was received.
|
||||
func (f *recentEventsFixture) delivery(
|
||||
t *testing.T,
|
||||
event *database.Event,
|
||||
targetID string,
|
||||
status database.DeliveryStatus,
|
||||
) *database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
return f.deliveryQueuedAt(
|
||||
t, event, targetID, status, event.CreatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
// deliveryQueuedAt records a delivery of the event to the target,
|
||||
// queued at queuedAt, as a replay is.
|
||||
func (f *recentEventsFixture) deliveryQueuedAt(
|
||||
t *testing.T,
|
||||
event *database.Event,
|
||||
targetID string,
|
||||
status database.DeliveryStatus,
|
||||
queuedAt time.Time,
|
||||
) *database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
dlv := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targetID,
|
||||
Status: status,
|
||||
}
|
||||
dlv.CreatedAt = queuedAt
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(dlv).Error)
|
||||
|
||||
return dlv
|
||||
}
|
||||
|
||||
// attempt records one attempt of the delivery that finished took
|
||||
// after the delivery was queued, with HTTP status code (0 for no
|
||||
// response).
|
||||
func (f *recentEventsFixture) attempt(
|
||||
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
result := &database.DeliveryResult{
|
||||
DeliveryID: dlv.ID,
|
||||
AttemptNum: 1,
|
||||
StatusCode: code,
|
||||
}
|
||||
result.CreatedAt = dlv.CreatedAt.Add(took)
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(result).Error)
|
||||
}
|
||||
|
||||
// statusCell is the status column's cell as the page renders it.
|
||||
func statusCell(class, text string) string {
|
||||
return `<span class="font-medium ` + class + `" ` + statusTitle +
|
||||
`>` + text + `</span>`
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list
|
||||
// holds the 50 newest events, newest first, and not one more.
|
||||
func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
base := time.Now().Add(-time.Hour)
|
||||
|
||||
for i := range 51 {
|
||||
f.event(
|
||||
t, fmt.Sprintf("application/x-recent-%02d", i), "{}",
|
||||
base.Add(time.Duration(i)*time.Second),
|
||||
)
|
||||
}
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
|
||||
assert.NotContains(t, body, "application/x-recent-00")
|
||||
assert.Contains(t, body, "application/x-recent-01")
|
||||
assert.Less(
|
||||
t,
|
||||
strings.Index(body, "application/x-recent-50"),
|
||||
strings.Index(body, "application/x-recent-49"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
|
||||
// time relative with the UTC timestamp on hover, its body size,
|
||||
// and its processing time once every delivery has finished.
|
||||
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||
|
||||
receivedAt := time.Now().Add(-210 * time.Second).
|
||||
UTC().Truncate(time.Second)
|
||||
|
||||
done := f.event(
|
||||
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
|
||||
)
|
||||
f.attempt(
|
||||
t,
|
||||
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
|
||||
0, 1500*time.Millisecond,
|
||||
)
|
||||
|
||||
waiting := f.event(t, "text/plain", "{}", receivedAt)
|
||||
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`<span title="`+receivedAt.Format(time.DateTime)+
|
||||
` UTC">3 minutes ago</span>`,
|
||||
)
|
||||
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
|
||||
assert.Contains(t, body, ">1.5s</span>")
|
||||
assert.Contains(t, body, ">in progress</span>")
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
|
||||
// webhook with exactly one HTTP target shows, colour-coded, what
|
||||
// that target answered for each event. The log target beside it
|
||||
// does not count against "exactly one".
|
||||
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, code := range []int{204, 302, 404, 503, 0} {
|
||||
dlv := f.delivery(
|
||||
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
||||
database.DeliveryStatusDelivered,
|
||||
)
|
||||
f.attempt(t, dlv, code, time.Second)
|
||||
}
|
||||
|
||||
f.delivery(
|
||||
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
f.event(t, contentTypeJSON, "{}", now)
|
||||
|
||||
// A replay is a newer delivery, and its answer is the one shown.
|
||||
replayed := f.event(t, contentTypeJSON, "{}", now)
|
||||
f.attempt(t, f.delivery(
|
||||
t, replayed, target.ID, database.DeliveryStatusFailed,
|
||||
), 502, time.Second)
|
||||
f.attempt(t, f.deliveryQueuedAt(
|
||||
t, replayed, target.ID, database.DeliveryStatusDelivered,
|
||||
now.Add(time.Minute),
|
||||
), 200, time.Second)
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(t, body, statusCell("text-green-600", "204"))
|
||||
assert.Contains(t, body, statusCell("text-gray-500", "302"))
|
||||
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
|
||||
assert.Contains(t, body, statusCell("text-red-600", "503"))
|
||||
assert.Contains(t, body, statusCell("text-red-600", "no response"))
|
||||
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
|
||||
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
|
||||
assert.Contains(t, body, statusCell("text-green-600", "200"))
|
||||
assert.NotContains(t, body, ">502<")
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
|
||||
// status column is absent when the webhook has no HTTP target or
|
||||
// more than one.
|
||||
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string][]database.TargetType{
|
||||
"none": {database.TargetTypeLog},
|
||||
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
|
||||
}
|
||||
|
||||
for name, types := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||
|
||||
for _, tt := range types {
|
||||
target := seedTarget(t, f.db, f.webhook.ID, tt)
|
||||
f.attempt(t, f.delivery(
|
||||
t, event, target.ID,
|
||||
database.DeliveryStatusDelivered,
|
||||
), 200, time.Second)
|
||||
}
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(t, body, `title="Body size"`)
|
||||
assert.NotContains(t, body, statusTitle)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -415,16 +415,21 @@ func (h *Handlers) renderSourceDetail(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Find(&targets)
|
||||
|
||||
var events []database.Event
|
||||
var events []RecentEventView
|
||||
|
||||
if h.dbMgr.DBExists(webhook.ID) {
|
||||
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
|
||||
if dbErr == nil {
|
||||
webhookDB.Where(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Order("created_at DESC").Limit(
|
||||
recentEventLimit,
|
||||
).Find(&events)
|
||||
events, dbErr = h.loadRecentEvents(
|
||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||
)
|
||||
}
|
||||
|
||||
if dbErr != nil {
|
||||
h.log.Error(
|
||||
"failed to load recent events",
|
||||
"webhook_id", webhook.ID, "error", dbErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,10 +108,10 @@ type failureWindow struct {
|
||||
//
|
||||
// A limiter that spends budget on arrival cannot protect a
|
||||
// single-admin product: behind the reverse proxy the deployment
|
||||
// requires, when TRUSTED_PROXIES does not cover it, every client
|
||||
// keys on the proxy, so a stranger trickling five POSTs a minute
|
||||
// keeps the one bucket full and the operator's own correct password
|
||||
// is answered 429 forever. There is no second administrative path.
|
||||
// requires, with TRUSTED_PROXIES unset, every client keys on the
|
||||
// proxy, so a stranger trickling five POSTs a minute keeps the one
|
||||
// bucket full and the operator's own correct password is answered 429
|
||||
// forever. There is no second administrative path.
|
||||
//
|
||||
// So budget is spent only by a FAILED verification. A correct
|
||||
// password is never throttled, whatever the counters say, which is
|
||||
|
||||
@@ -123,8 +123,9 @@ func bucketKey(addr netip.Addr) string {
|
||||
return prefix.String()
|
||||
}
|
||||
|
||||
// isTrustedProxy reports whether addr belongs to a network in
|
||||
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
||||
// isTrustedProxy reports whether addr belongs to a network the
|
||||
// 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 {
|
||||
for _, prefix := range m.params.Config.TrustedProxies {
|
||||
if prefix.Contains(addr) {
|
||||
|
||||
@@ -426,8 +426,8 @@ func assertSharedBucket(
|
||||
}
|
||||
|
||||
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
||||
// this gating exists for: from a peer that is not a trusted
|
||||
// proxy, a client that rotates a forwarded header on every
|
||||
// this gating exists for: with no trusted proxies configured (the
|
||||
// default), a client that rotates a forwarded header on every
|
||||
// request must stay in one bucket. If forwarded headers were
|
||||
// trusted unconditionally, each spoofed value would mint a fresh
|
||||
// bucket and the limit would stop no one.
|
||||
|
||||
@@ -92,11 +92,25 @@ func (s *Server) setupGlobalMiddleware() {
|
||||
func (s *Server) setupRoutes() {
|
||||
s.router.Get("/", s.h.HandleIndex())
|
||||
|
||||
s.router.Mount(
|
||||
"/s",
|
||||
http.StripPrefix("/s", http.FileServer(http.FS(static.Static))),
|
||||
// Static assets answer GET and HEAD only. chi's default 405
|
||||
// carries no Allow header, so this group supplies its own.
|
||||
staticFiles := http.StripPrefix(
|
||||
"/s", http.FileServer(http.FS(static.Static)),
|
||||
)
|
||||
|
||||
s.router.Route("/s", func(r chi.Router) {
|
||||
r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(
|
||||
w,
|
||||
"Method Not Allowed",
|
||||
http.StatusMethodNotAllowed,
|
||||
)
|
||||
})
|
||||
r.Method(http.MethodGet, "/*", staticFiles)
|
||||
r.Method(http.MethodHead, "/*", staticFiles)
|
||||
})
|
||||
|
||||
s.router.Route("/api/v1", func(_ chi.Router) {
|
||||
// API routes will be added here.
|
||||
})
|
||||
@@ -140,12 +154,11 @@ func (s *Server) setupPageRoutes() {
|
||||
r.Use(s.mw.NoCache())
|
||||
|
||||
// The login POST carries no pre-emptive rate limiter. Behind
|
||||
// the reverse proxy production requires, when TRUSTED_PROXIES
|
||||
// does not cover it, every client shares one bucket, so a
|
||||
// limiter spent on arrival lets any stranger deny the operator
|
||||
// the only administrative path. The handler verifies
|
||||
// credentials first and charges only failures; see
|
||||
// Handlers.authenticateUser.
|
||||
// the reverse proxy production requires, with TRUSTED_PROXIES
|
||||
// unset, every client shares one bucket, so a limiter spent
|
||||
// on arrival lets any stranger deny the operator the only
|
||||
// administrative path. The handler verifies credentials first
|
||||
// and charges only failures; see Handlers.authenticateUser.
|
||||
r.Get("/login", s.h.HandleLoginPage())
|
||||
r.Post("/login", s.h.HandleLoginSubmit())
|
||||
|
||||
|
||||
@@ -396,13 +396,15 @@ func (e *testEnv) storedHash(t *testing.T, username string) string {
|
||||
|
||||
// --- /s static group ---
|
||||
|
||||
// TestStaticServesEveryMethod pins what the static mount actually
|
||||
// answers. chi's Mount registers the handler for all methods and
|
||||
// http.FileServer only special-cases HEAD (by suppressing the body),
|
||||
// so a POST or a DELETE to an asset is served the file rather than
|
||||
// refused. The README documents this; the test is what keeps the two
|
||||
// from drifting.
|
||||
func TestStaticServesEveryMethod(t *testing.T) {
|
||||
// TestStaticServesOnlyGetAndHead pins the methods the static group
|
||||
// answers: GET and HEAD are served the asset, and the other methods
|
||||
// chi routes (POST, PUT, DELETE and the rest) are refused with 405
|
||||
// and an Allow header naming those two. A method chi does not route,
|
||||
// such as PROPFIND, is refused with 405 by the top-level router
|
||||
// before it reaches the static group, so it gets no Allow header.
|
||||
// The README documents this; the test is what keeps the two from
|
||||
// drifting.
|
||||
func TestStaticServesOnlyGetAndHead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
@@ -417,6 +419,7 @@ func TestStaticServesEveryMethod(t *testing.T) {
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
http.MethodDelete,
|
||||
"PROPFIND",
|
||||
} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -428,18 +431,38 @@ func TestStaticServesEveryMethod(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code,
|
||||
"static mount answers every method")
|
||||
|
||||
if method == http.MethodHead {
|
||||
assert.Empty(t, w.Body.Bytes(),
|
||||
"HEAD must not carry a body")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch method {
|
||||
case http.MethodGet:
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, body, w.Body.Bytes(),
|
||||
"the asset itself is returned")
|
||||
case http.MethodHead:
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Empty(t, w.Body.Bytes(),
|
||||
"HEAD must not carry a body")
|
||||
case "PROPFIND":
|
||||
assert.Equal(
|
||||
t, http.StatusMethodNotAllowed, w.Code,
|
||||
)
|
||||
assert.Empty(t, w.Header().Get("Allow"),
|
||||
"chi refuses a method it does not route "+
|
||||
"before the static group runs")
|
||||
assert.NotContains(
|
||||
t, w.Body.String(), string(body),
|
||||
"a refused method must not get the asset",
|
||||
)
|
||||
default:
|
||||
assert.Equal(
|
||||
t, http.StatusMethodNotAllowed, w.Code,
|
||||
)
|
||||
assert.Equal(
|
||||
t, "GET, HEAD", w.Header().Get("Allow"),
|
||||
)
|
||||
assert.NotContains(
|
||||
t, w.Body.String(), string(body),
|
||||
"a refused method must not get the asset",
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,12 +187,24 @@
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
||||
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
||||
{{if .ResubmittedFromID}}
|
||||
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
||||
<span title="Body size">{{.Size}}</span>
|
||||
{{if .ProcessingTime}}
|
||||
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
||||
{{end}}
|
||||
{{if .Status}}
|
||||
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
||||
{{end}}
|
||||
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
Reference in New Issue
Block a user