Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b19c48e9 | ||
|
|
98f719ded3 | ||
|
|
0da5f26bdf | ||
|
|
a9f7448ff6 | ||
|
|
999654f8f6 | ||
|
|
e1a11933f0 | ||
|
|
9d29baaa2d |
+2
-4
@@ -3,10 +3,8 @@
|
||||
# stage of the Dockerfile.
|
||||
.git/
|
||||
bin/
|
||||
# Third-party browser assets are fetched and hash-verified inside the build by
|
||||
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
|
||||
# from supplying the bytes that get shipped. The script and its
|
||||
# static/vendor.sha256 manifest stay in the context.
|
||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||
# needed. The tarball in 3p/ must stay in the context.
|
||||
static/js/alpine.min.js
|
||||
*.md
|
||||
LICENSE
|
||||
|
||||
+2
-3
@@ -46,7 +46,6 @@ temp/
|
||||
# CI cache barrier, written into the build context by the check workflow
|
||||
.ci-fingerprint
|
||||
|
||||
# Third-party browser assets, fetched and hash-verified by
|
||||
# script/fetch-assets against static/vendor.sha256. Not committed:
|
||||
# REPO_POLICIES.md forbids minified bundles in version control.
|
||||
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
||||
# what is committed.
|
||||
/static/js/alpine.min.js
|
||||
Binary file not shown.
+4
-11
@@ -51,15 +51,8 @@ RUN go mod download
|
||||
# the lint stage above.
|
||||
COPY . .
|
||||
|
||||
# Fetch the third-party browser assets the UI serves. They are not committed
|
||||
# (REPO_POLICIES.md forbids minified bundles in version control) and
|
||||
# .dockerignore keeps any host copy out of the build context, so this step is
|
||||
# the only way they enter the image. Each download is checked against a
|
||||
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
|
||||
# hashes against the bytes go:embed actually put in the binary.
|
||||
RUN script/fetch-assets
|
||||
|
||||
# Run tests and build
|
||||
# Run tests and build. Both first run script/assets, which extracts Alpine.js
|
||||
# from its tarball in 3p/.
|
||||
RUN make test
|
||||
|
||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||
@@ -67,8 +60,8 @@ RUN make test
|
||||
# host and passes it in. The default is what a bare `docker build .`
|
||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
||||
#
|
||||
# Declared here, below the test and asset steps, so a changed version
|
||||
# does not invalidate their cached layers.
|
||||
# Declared here, below the test step, so a changed version does not
|
||||
# invalidate its cached layer.
|
||||
ARG VERSION=unknown
|
||||
|
||||
RUN make build VERSION="$VERSION"
|
||||
|
||||
@@ -28,7 +28,7 @@ setup:
|
||||
@script/setup
|
||||
|
||||
assets:
|
||||
@script/fetch-assets
|
||||
@script/assets
|
||||
|
||||
test:
|
||||
@script/test
|
||||
@@ -45,13 +45,13 @@ fmt-check:
|
||||
check:
|
||||
@script/check
|
||||
|
||||
build:
|
||||
build: assets
|
||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
|
||||
dev:
|
||||
dev: assets
|
||||
go run ./cmd/webhooker
|
||||
|
||||
deps:
|
||||
|
||||
@@ -21,9 +21,6 @@ before deploying one.
|
||||
- Go 1.26.1+ (the version in `go.mod`)
|
||||
- Docker (for linting, for the test stage of the CI gate, and for
|
||||
containerized deployment)
|
||||
- `curl`, used by `script/fetch-assets` to download the third-party
|
||||
browser assets, which are not committed (`make bootstrap` installs
|
||||
it if missing)
|
||||
|
||||
golangci-lint is not a prerequisite and must not be installed on the
|
||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||
@@ -36,9 +33,7 @@ digest-pinned linter image via `Dockerfile.lint`.
|
||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||
cd webhooker
|
||||
|
||||
# Install Go dependencies and the third-party browser assets.
|
||||
# `make deps` alone is not enough: it only runs go mod download/tidy,
|
||||
# and the checks below need the fetched assets.
|
||||
# Install the Go toolchain if missing, and the Go dependencies
|
||||
make bootstrap
|
||||
|
||||
# Run all checks (test, lint, format check)
|
||||
@@ -58,7 +53,7 @@ make docker
|
||||
```bash
|
||||
make bootstrap # Install all dependencies (idempotent)
|
||||
make setup # Bootstrap + install git pre-commit hook
|
||||
make assets # Fetch + verify third-party browser assets
|
||||
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
||||
make fmt # Format code (gofmt + goimports)
|
||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||
@@ -147,7 +142,7 @@ TTY detection, and security headers are always applied.
|
||||
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
|
||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||
|
||||
#### Allowing egress to your own network
|
||||
@@ -379,41 +374,37 @@ 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 name
|
||||
the addresses of your reverse proxies and nothing else.
|
||||
`X-Forwarded-For` header the rate limiters believe, so it should cover
|
||||
the addresses of your reverse proxies.
|
||||
|
||||
`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. 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.
|
||||
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`. A set value replaces the default
|
||||
entirely. A set but unparseable value aborts startup.
|
||||
|
||||
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.
|
||||
If any client can reach webhooker, or the proxy in front of it, from an
|
||||
RFC 1918 source address (directly, or through anything that can
|
||||
rewrite source addresses, such as NAT or a published container port),
|
||||
set `TRUSTED_PROXIES` to the proxy's address alone, or every rate
|
||||
limit, the webhook receiver's included, can be bypassed by those
|
||||
clients. The address to set is the `remoteIP` field of the
|
||||
`http request` log line for a request that came through the proxy.
|
||||
|
||||
Behind a proxy the list does not cover, 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.
|
||||
|
||||
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
|
||||
@@ -429,20 +420,10 @@ instead, since past such an entry the chain is not the shape assumed
|
||||
here. The peer address is likewise used when the header is absent or
|
||||
every hop in it is a trusted proxy.
|
||||
|
||||
Two operator requirements follow:
|
||||
|
||||
- Your proxy must **append** the peer address to `X-Forwarded-For`
|
||||
(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`,
|
||||
Caddy and AWS ALB by default), and must append a bare address with
|
||||
no port.
|
||||
- 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. 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.
|
||||
Your proxy must therefore **append** the peer address to
|
||||
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, HAProxy
|
||||
`option forwardfor`, Caddy and AWS ALB by default), and must append a
|
||||
bare address with no port.
|
||||
|
||||
#### Sessions
|
||||
|
||||
@@ -741,10 +722,15 @@ repository's `Dockerfile` and runs it. The app needs:
|
||||
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
||||
- **Environment variables:**
|
||||
- `WEBHOOKER_ENVIRONMENT=prod`
|
||||
- `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).
|
||||
- `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to
|
||||
your reverse proxy's address alone if that address is outside
|
||||
those ranges, or if any client can reach webhooker, or the proxy,
|
||||
from an RFC 1918 source address (directly, or through anything
|
||||
that can rewrite source addresses, such as NAT or a published
|
||||
container port). The `remoteIP` field of the `http request` log
|
||||
line for a request that came through the proxy shows that
|
||||
address; 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`.
|
||||
@@ -807,12 +793,16 @@ 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. **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.
|
||||
3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a
|
||||
proxy it does not cover, every rate limiter keys on the proxy, so
|
||||
all clients share one bucket per limit. Unset, the list is the RFC
|
||||
1918 ranges, which do not cover a proxy that reaches the binary
|
||||
itself over loopback (the binary bound to `127.0.0.1`). With the
|
||||
image, the address to check is the `remoteIP` field of the
|
||||
`http request` log line for a request that came through the proxy.
|
||||
If any client can reach webhooker, or the proxy, from an RFC 1918
|
||||
source address, set the list to the proxy's address alone. See
|
||||
[Trusted proxies](#trusted-proxies).
|
||||
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
||||
port. webhooker's Origin/Referer check compares against the host it
|
||||
was given, so on any port other than 443 `$host` makes every form
|
||||
@@ -1221,14 +1211,15 @@ This repository adheres to the
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||
`version` are inline commands with no script behind them, though
|
||||
`build` and `version` both take their value from `script/version`.
|
||||
`version` are inline commands with no script behind them, though `build`,
|
||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
||||
take their value from `script/version`.
|
||||
|
||||
`make check` needs the third-party browser assets in `static/`, which
|
||||
are not committed, so run `make bootstrap` (or just `make assets`) once
|
||||
after cloning. Without them the tests fail with a message naming that
|
||||
remedy. `make check` does not fetch them itself because it must not
|
||||
change any files in the repo.
|
||||
`script/test`, `make build` and `make dev` each run `script/assets`
|
||||
first, which writes the ignored `static/js/alpine.min.js` (see
|
||||
[Third-party browser assets](#third-party-browser-assets)), so
|
||||
`make test`, `make check` and the pre-commit hook work on a fresh clone
|
||||
without a separate step.
|
||||
|
||||
We provide:
|
||||
|
||||
@@ -1236,8 +1227,8 @@ We provide:
|
||||
- `script/setup` — make a fresh clone ready for development
|
||||
(bootstrap, then install-precommit)
|
||||
- `script/projectname` — output the project name ("webhooker")
|
||||
- `script/fetch-assets` — download the third-party browser assets into
|
||||
`static/`, verifying each against its pinned sha256
|
||||
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
||||
[Third-party browser assets](#third-party-browser-assets))
|
||||
- `script/test` — run the test suite
|
||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||
- `script/fmt` — format all code (writes)
|
||||
@@ -1259,24 +1250,25 @@ We provide:
|
||||
|
||||
## Third-party browser assets
|
||||
|
||||
The web UI serves one third-party script, Alpine.js. It is **not** committed:
|
||||
a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
|
||||
both committed build artifacts and unpinned external references.
|
||||
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||
publishes it. It is a dependency, not this repo's build output, so
|
||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||
`vendor/` directory as its module vendor directory.
|
||||
|
||||
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
|
||||
download against a hardcoded sha256, and installs it under `static/`. The
|
||||
sha256 of every installed asset is recorded in `static/vendor.sha256`, and
|
||||
`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
|
||||
against that manifest — so the pin is enforced on what actually ships, not
|
||||
merely written down. Any mismatch fails the build.
|
||||
`script/assets` (`make assets`) extracts the browser build,
|
||||
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`,
|
||||
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
||||
it first, and the Dockerfile builds through `make test` and `make build`, so
|
||||
nothing downloads Alpine.js. The extracted file is not committed, and
|
||||
`.dockerignore` keeps any host copy out of the build context.
|
||||
|
||||
`make bootstrap` runs the fetch for local development, and the Dockerfile
|
||||
runs it in the build stage; `.gitignore` and `.dockerignore` keep the
|
||||
artifact out of both the repo and the build context.
|
||||
|
||||
To move to a new version: update the version, URL, and tarball sha256 in
|
||||
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
|
||||
run `make assets && make check`.
|
||||
To move to a new version: download
|
||||
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||
against the `dist.integrity` hash listed at
|
||||
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||
with it, update its file name in `script/assets`, and run `make check`.
|
||||
|
||||
## Rationale
|
||||
|
||||
@@ -1363,10 +1355,11 @@ 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` 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))
|
||||
`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))
|
||||
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||
`/metrics` behind basic auth
|
||||
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||
@@ -2536,47 +2529,44 @@ 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
|
||||
arrived uncut — never the numbers.
|
||||
|
||||
Every limiter here — receiver, login, and password change — identifies
|
||||
the client the same way, through one shared key function: the
|
||||
connection's own address, unless the peer is listed in
|
||||
`TRUSTED_PROXIES`, in which case the forwarded client address is used
|
||||
instead. That address becomes a bucket by family: IPv4 keys on the full
|
||||
address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
||||
Every limiter here — receiver, login, password change, delivery replay
|
||||
and event resubmit — identifies the client the same way, through one
|
||||
shared key function: the connection's own address, unless the peer is
|
||||
inside `TRUSTED_PROXIES`, in which case the forwarded client address is
|
||||
used instead. That address becomes a bucket by family: IPv4 keys on
|
||||
the full address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
||||
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
||||
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). 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
|
||||
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
|
||||
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: 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`.
|
||||
than for the per-entrypoint one: with every request keyed on the
|
||||
proxy, 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.
|
||||
- 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 set `TRUSTED_PROXIES`; webhooker warns at startup
|
||||
whenever it is empty, in any environment.
|
||||
never affected, because it is never counted.
|
||||
|
||||
#### The login endpoint
|
||||
|
||||
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
|
||||
arrival is a lockout in this deployment shape: sharing one bucket, a
|
||||
arrival is a lockout wherever clients share one bucket, as they do
|
||||
behind a reverse proxy that `TRUSTED_PROXIES` does not cover: a
|
||||
stranger sending five POSTs a minute — about 0.08 requests per second,
|
||||
from anywhere — keeps it permanently full, and the operator has no
|
||||
second administrative path. So the handler inverts the order:
|
||||
@@ -2673,8 +2663,10 @@ 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. Setting `TRUSTED_PROXIES` does not stop the
|
||||
saturation, but it makes the source visible in the failure logs.
|
||||
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)).
|
||||
|
||||
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
|
||||
@@ -2755,6 +2747,8 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
||||
|
||||
```
|
||||
webhooker/
|
||||
├── 3p/
|
||||
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
|
||||
├── cmd/webhooker/
|
||||
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
||||
├── internal/
|
||||
@@ -2848,8 +2842,7 @@ webhooker/
|
||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
||||
│ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed
|
||||
│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against
|
||||
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||
├── script/ # Scripts to Rule Them All entrypoints
|
||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||
@@ -3031,10 +3024,9 @@ 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` names the reverse proxy; unset, every client
|
||||
`TRUSTED_PROXIES` covers the reverse proxy; otherwise every client
|
||||
shares one bucket, which costs precision rather than availability
|
||||
(see [Rate Limiting](#rate-limiting)). webhooker warns at startup
|
||||
whenever `TRUSTED_PROXIES` is empty
|
||||
(see [Rate Limiting](#rate-limiting))
|
||||
- Prometheus metrics behind basic auth
|
||||
- Static assets embedded in binary (no filesystem access needed at
|
||||
runtime)
|
||||
@@ -3161,14 +3153,14 @@ version is fixed independently of the compiler's:
|
||||
`make fmt-check`, then `golangci-lint config verify` and
|
||||
`golangci-lint run`, both with `--network=none`.
|
||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||
to download and verify the third-party browser assets, then runs
|
||||
`make test` and `make build`, and finally rebuilds the binary with
|
||||
`CGO_ENABLED=1` and static linking so it runs on musl. Both builds
|
||||
go through `make build`, the relink adding its `-extldflags` via
|
||||
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
||||
The version arrives as the `VERSION` build arg, since the context
|
||||
has no `.git` (see [Version stamping](#version-stamping)).
|
||||
stage passing (it copies a file from it), runs `make test` and
|
||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||
runs on musl. Both builds go through `make build`, the relink adding
|
||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||
stamps the version. The version arrives as the `VERSION` build arg,
|
||||
since the context has no `.git` (see
|
||||
[Version stamping](#version-stamping)).
|
||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||
directory for all SQLite databases, exposes port 8080, and includes
|
||||
|
||||
+22
-60
@@ -75,6 +75,11 @@ 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
|
||||
@@ -172,13 +177,14 @@ 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. 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.
|
||||
// only forwarded header read. Unless TRUSTED_PROXIES is set it
|
||||
// is the RFC 1918 private ranges (defaultTrustedProxies); a set
|
||||
// value replaces them. If any client can reach the process, or
|
||||
// the proxy in front of it, from an RFC 1918 source address
|
||||
// (directly, or through anything that can rewrite source
|
||||
// addresses, such as NAT or a published container port), it
|
||||
// must be set to the proxy's address alone, or every rate limit
|
||||
// can be bypassed by those clients.
|
||||
TrustedProxies []netip.Prefix
|
||||
|
||||
// AllowedEgressCIDRs is the set of networks a delivery target
|
||||
@@ -460,14 +466,15 @@ 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 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) {
|
||||
// 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) {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
v = defaultValue
|
||||
}
|
||||
|
||||
var prefixes []netip.Prefix
|
||||
@@ -681,12 +688,12 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -760,50 +767,6 @@ 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.
|
||||
@@ -849,7 +812,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
||||
)
|
||||
|
||||
s.warnSharedRateLimitBucket(log)
|
||||
s.warnEgressAllowlist(log)
|
||||
|
||||
return s, nil
|
||||
|
||||
+14
-101
@@ -551,6 +551,11 @@ 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
|
||||
@@ -559,18 +564,21 @@ 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: []string{},
|
||||
expected: defaultProxies,
|
||||
},
|
||||
{
|
||||
name: "blank value trusts nothing",
|
||||
name: "blank value uses default",
|
||||
set: true,
|
||||
value: " ",
|
||||
expected: []string{},
|
||||
expected: defaultProxies,
|
||||
},
|
||||
{
|
||||
name: "set value replaces the default entirely",
|
||||
set: true,
|
||||
value: "203.0.113.7",
|
||||
expected: []string{"203.0.113.7/32"},
|
||||
},
|
||||
{
|
||||
name: caseValidValueParsed,
|
||||
@@ -845,101 +853,6 @@ 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,21 +6,6 @@ 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
|
||||
|
||||
@@ -79,9 +79,3 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
||||
func DummyPasswordHashForTest() string {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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,24 +63,10 @@ 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
|
||||
func HashPassword(password string) (string, error) {
|
||||
return hashPasswordWith(password, hashConfig)
|
||||
}
|
||||
config := DefaultPasswordConfig()
|
||||
|
||||
// hashPasswordWith generates an Argon2id hash of the password with
|
||||
// the given parameters.
|
||||
func hashPasswordWith(
|
||||
password string,
|
||||
config *PasswordConfig,
|
||||
) (string, error) {
|
||||
// Generate a salt
|
||||
salt := make([]byte, config.SaltLen)
|
||||
|
||||
|
||||
@@ -192,36 +192,6 @@ 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
|
||||
// path. Login charges an unknown username a verification against a
|
||||
// dummy hash so that a nonexistent account is not answered in
|
||||
|
||||
@@ -45,20 +45,3 @@ func NewTestWebhookDBManagerWithLogger(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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,9 +103,10 @@ 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, 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.
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// TRUSTED_PROXIES defaults to empty, so every client — attacker
|
||||
// when TRUSTED_PROXIES does not cover it 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 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.
|
||||
// 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.
|
||||
//
|
||||
// This fails if credentials stop being verified ahead of the limiter.
|
||||
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
// single-admin product: behind the reverse proxy the deployment
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// So budget is spent only by a FAILED verification. A correct
|
||||
// password is never throttled, whatever the counters say, which is
|
||||
|
||||
@@ -123,9 +123,8 @@ func bucketKey(addr netip.Addr) string {
|
||||
return prefix.String()
|
||||
}
|
||||
|
||||
// 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.
|
||||
// isTrustedProxy reports whether addr belongs to a network in
|
||||
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
||||
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
||||
for _, prefix := range m.params.Config.TrustedProxies {
|
||||
if prefix.Contains(addr) {
|
||||
|
||||
@@ -384,8 +384,8 @@ const (
|
||||
// trustedProxyCIDR is the proxy network the forwarded-path
|
||||
// tests configure, and trustedPeer an address inside it. A
|
||||
// production deployment is required to run behind a reverse
|
||||
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
||||
// bucketing has to hold in.
|
||||
// proxy that TRUSTED_PROXIES covers, either by the default or by
|
||||
// a set value, so this is the shape the bucketing has to hold in.
|
||||
trustedProxyCIDR = "10.0.0.0/8"
|
||||
trustedPeer = "10.0.0.1:44444"
|
||||
)
|
||||
@@ -426,8 +426,8 @@ func assertSharedBucket(
|
||||
}
|
||||
|
||||
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
||||
// this gating exists for: with no trusted proxies configured (the
|
||||
// default), a client that rotates a forwarded header on every
|
||||
// this gating exists for: from a peer that is not a trusted
|
||||
// proxy, 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.
|
||||
@@ -1097,8 +1097,9 @@ func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
|
||||
// that arrives from trustedPeer — a configured trusted proxy — and
|
||||
// names forwarded as its client in X-Forwarded-For. That is the
|
||||
// production path: a deployment is required to run behind a reverse
|
||||
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
||||
// peer, is what the limiters bucket on there.
|
||||
// proxy that TRUSTED_PROXIES covers, either by the default or by a
|
||||
// set value, so the forwarded address, not the peer, is what the
|
||||
// limiters bucket on there.
|
||||
func forwardedKeyFor(
|
||||
t *testing.T, m *middleware.Middleware, forwarded string,
|
||||
) string {
|
||||
@@ -1178,9 +1179,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
||||
//
|
||||
// Every existing test of this fallback uses an IPv4 proxy, where
|
||||
// bucketKey is the identity function, so replacing the call with
|
||||
// peer.String() leaves the whole suite green. Only operator-listed
|
||||
// addresses reach this line and the fallback is fail-closed, so this
|
||||
// pins behaviour rather than fixing a defect.
|
||||
// peer.String() leaves the whole suite green. Only addresses inside
|
||||
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so
|
||||
// this pins behaviour rather than fixing a defect.
|
||||
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
||||
t *testing.T,
|
||||
) {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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.
|
||||
//
|
||||
// One application per test function, not per case: every start that
|
||||
// finds no account seeds one with an Argon2id hash, and this package's
|
||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
||||
// budget is not the place to spend that repeatedly.
|
||||
func newServerApp(
|
||||
t *testing.T, dir string,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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,11 +154,12 @@ func (s *Server) setupPageRoutes() {
|
||||
r.Use(s.mw.NoCache())
|
||||
|
||||
// The login POST carries no pre-emptive rate limiter. Behind
|
||||
// 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.
|
||||
// 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.
|
||||
r.Get("/login", s.h.HandleLoginPage())
|
||||
r.Post("/login", s.h.HandleLoginSubmit())
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||
// template loads on each page and fetches it through the real router.
|
||||
// Alpine.js is fetched at build time rather than committed, so nothing
|
||||
// in the repo guarantees it is present: this is the check that the page
|
||||
// still gets the JavaScript it asks for.
|
||||
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
|
||||
// file is not in the tree: this is the check that the page still gets
|
||||
// the JavaScript it asks for.
|
||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# script/assets: extract Alpine.js from its npm package tarball, committed
|
||||
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
|
||||
# extracted file is not committed. script/test, make build and make dev run
|
||||
# this first.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
|
||||
>static/js/alpine.min.js
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+1
-8
@@ -4,9 +4,7 @@
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
|
||||
# script/fetch-assets, which installs the hash-pinned third-party browser
|
||||
# assets the repo does not commit.
|
||||
# only in docker, via script/lint and Dockerfile.lint.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -69,11 +67,6 @@ main() {
|
||||
|
||||
go mod download
|
||||
|
||||
# Third-party browser assets are not committed; fetch and verify them
|
||||
# so a fresh clone can build and test.
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
"$ROOT/script/fetch-assets"
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||
# extension to scripts-to-rule-them-all.
|
||||
# Writes only the ignored static/js/alpine.min.js, through script/test.
|
||||
# Generic: usually needs no adaptation.
|
||||
set -eu
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/fetch-assets: download the third-party browser assets the web UI
|
||||
# ships and install them under static/. Minified bundles are not committed
|
||||
# (REPO_POLICIES.md: no build artifacts in version control), so the build
|
||||
# fetches them here. Every download is verified against a hardcoded sha256
|
||||
# before it is installed, and any mismatch aborts. Idempotent: an asset
|
||||
# already present with its pinned hash is left alone.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# The sha256 of each installed asset lives in static/vendor.sha256, in
|
||||
# sha256sum(1) format, with paths relative to static/. That file is the
|
||||
# single source of truth: this script verifies against it, and
|
||||
# static/vendor_test.go asserts the bytes embedded into the binary match
|
||||
# it, so the hash cannot rot into a value nothing checks.
|
||||
MANIFEST="static/vendor.sha256"
|
||||
|
||||
# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the
|
||||
# publisher of record; the jsDelivr and unpkg copies are mirrors of this
|
||||
# same tarball. dist/cdn.min.js is the browser build Alpine publishes for
|
||||
# a <script> tag.
|
||||
ALPINE_VERSION="3.14.9"
|
||||
ALPINE_URL="https://registry.npmjs.org/alpinejs/-/alpinejs-${ALPINE_VERSION}.tgz"
|
||||
# sha256 of alpinejs-3.14.9.tgz
|
||||
ALPINE_TARBALL_SHA256="97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a"
|
||||
ALPINE_MEMBER="package/dist/cdn.min.js"
|
||||
ALPINE_DEST="js/alpine.min.js"
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | cut -d' ' -f1
|
||||
else
|
||||
shasum -a 256 "$1" | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
# expected_sha256 <path-relative-to-static>
|
||||
expected_sha256() {
|
||||
awk -v want="$1" '$2 == want { print $1; found = 1 }
|
||||
END { if (!found) exit 1 }' "$ROOT/$MANIFEST"
|
||||
}
|
||||
|
||||
# verify <file> <expected-sha256> <what>
|
||||
verify() {
|
||||
actual="$(sha256_of "$1")"
|
||||
if [ "$actual" != "$2" ]; then
|
||||
echo "fetch-assets: sha256 mismatch for $3" >&2
|
||||
echo " expected: $2" >&2
|
||||
echo " actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# up_to_date <path-relative-to-static> <expected-sha256>
|
||||
up_to_date() {
|
||||
[ -f "$ROOT/static/$1" ] || return 1
|
||||
[ "$(sha256_of "$ROOT/static/$1")" = "$2" ]
|
||||
}
|
||||
|
||||
fetch_alpine() {
|
||||
want="$(expected_sha256 "$ALPINE_DEST")"
|
||||
|
||||
if up_to_date "$ALPINE_DEST" "$want"; then
|
||||
echo "fetch-assets: static/$ALPINE_DEST already at $want"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "fetch-assets: fetching Alpine.js $ALPINE_VERSION from $ALPINE_URL"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
curl -fsSL -o "$tmp/alpine.tgz" "$ALPINE_URL"
|
||||
verify "$tmp/alpine.tgz" "$ALPINE_TARBALL_SHA256" "alpinejs-${ALPINE_VERSION}.tgz"
|
||||
tar -xzOf "$tmp/alpine.tgz" "$ALPINE_MEMBER" >"$tmp/alpine.min.js"
|
||||
verify "$tmp/alpine.min.js" "$want" "$ALPINE_MEMBER from alpinejs-${ALPINE_VERSION}.tgz"
|
||||
|
||||
mkdir -p "$(dirname "$ROOT/static/$ALPINE_DEST")"
|
||||
cp "$tmp/alpine.min.js" "$ROOT/static/$ALPINE_DEST"
|
||||
rm -rf "$tmp"
|
||||
trap - EXIT INT TERM
|
||||
echo "fetch-assets: installed static/$ALPINE_DEST ($want)"
|
||||
}
|
||||
|
||||
# Re-check every manifest entry against what is now on disk, so an entry
|
||||
# no script installs fails loudly instead of passing silently.
|
||||
verify_manifest() {
|
||||
while read -r want path; do
|
||||
case "$want" in '' | '#'*) continue ;; esac
|
||||
if [ ! -f "$ROOT/static/$path" ]; then
|
||||
echo "fetch-assets: $MANIFEST lists static/$path, which is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
verify "$ROOT/static/$path" "$want" "static/$path"
|
||||
done <"$ROOT/$MANIFEST"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
fetch_alpine
|
||||
verify_manifest
|
||||
echo "fetch-assets: all assets in $MANIFEST verified"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+2
-6
@@ -22,18 +22,14 @@
|
||||
# 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
|
||||
# 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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
||||
"$ROOT/script/assets"
|
||||
go test -v -race -timeout 90s ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
|
||||
@@ -1,92 +0,0 @@
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/webhooker/static"
|
||||
)
|
||||
|
||||
const manifestPath = "vendor.sha256"
|
||||
|
||||
// fetchHint is appended to every failure here: the assets the manifest
|
||||
// covers are fetched by the build, not committed, so a fresh clone that
|
||||
// has not run script/fetch-assets fails this test and should be told why.
|
||||
const fetchHint = "run `script/fetch-assets` (or `make assets`) to install " +
|
||||
"the pinned third-party assets"
|
||||
|
||||
// TestVendoredAssetsMatchManifest asserts that every asset listed in
|
||||
// static/vendor.sha256 is embedded in the binary with exactly the pinned
|
||||
// bytes. script/fetch-assets verifies the same hashes at download time;
|
||||
// this test verifies them again on what actually ships, so a build that
|
||||
// skipped, cached, or subverted the fetch cannot produce a binary serving
|
||||
// unpinned third-party JavaScript.
|
||||
func TestVendoredAssetsMatchManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := readManifest(t)
|
||||
require.NotEmpty(t, entries, "%s lists no assets", manifestPath)
|
||||
|
||||
for path, want := range entries {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := static.Static.ReadFile(path)
|
||||
require.NoErrorf(
|
||||
t, err,
|
||||
"%s is listed in %s but is not embedded; %s",
|
||||
path, manifestPath, fetchHint,
|
||||
)
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
require.Equalf(
|
||||
t, want, got,
|
||||
"embedded %s does not match its pinned sha256 in %s; %s",
|
||||
path, manifestPath, fetchHint,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// readManifest parses static/vendor.sha256, which is in sha256sum(1)
|
||||
// format with paths relative to static/.
|
||||
func readManifest(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.Open(manifestPath)
|
||||
require.NoError(t, err, "opening %s", manifestPath)
|
||||
|
||||
defer func() { require.NoError(t, f.Close()) }()
|
||||
|
||||
entries := make(map[string]string)
|
||||
scanner := bufio.NewScanner(f)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
require.Lenf(
|
||||
t, fields, 2,
|
||||
"%s: malformed entry %q, want \"<sha256> <path>\"",
|
||||
manifestPath, line,
|
||||
)
|
||||
|
||||
sum, path := fields[0], fields[1]
|
||||
require.Lenf(t, sum, 64, "%s: %q is not a sha256", manifestPath, sum)
|
||||
entries[path] = sum
|
||||
}
|
||||
|
||||
require.NoError(t, scanner.Err(), "reading %s", manifestPath)
|
||||
|
||||
return entries
|
||||
}
|
||||
Reference in New Issue
Block a user