Author SHA1 Message Date
clawbot 718865e075 Render admin page errors in the normal layout (closes #382)
check / check (push) Waiting to run
Every 400, 403, 404 and 500 on an admin page now answers with an
error page in the normal layout: one fixed line for the status and a
link back to the webhook list, or to sign-in when nobody is signed
in. The router's handler for unknown paths and the CSRF middleware's
refusal use the same page. Status codes are unchanged. The receiver,
the healthcheck and /metrics keep their plain answers. If the error
page itself cannot render, the answer is the same status in plain
text.

Model: opus-5-5
2026-10-01 20:52:32 +00:00
clawbot 9d29baaa2d Commit the Alpine.js tarball in 3p/ and extract it at build time (closes #345)
check / check (push) Waiting to run
The build no longer downloads Alpine.js. Its npm package tarball is committed as 3p/alpinejs-3.14.9.tgz, byte for byte the file script/fetch-assets downloaded, with the sha256 that script pinned. script/assets (make assets) extracts package/dist/cdn.min.js to the ignored static/js/alpine.min.js; script/test runs it, so make test, make check, the pre-commit hook and the Dockerfile get the file with no network access, and make build, run and dev run it too.

Removed: script/fetch-assets, its Dockerfile step, static/vendor.sha256 and static/vendor_test.go. The README describes the new flow.

Model: opus-5-5
2026-10-01 22:44:41 +02:00
clawbot 507980a347 Bound username length at creation (closes #184)
check / check (push) Successful in 4m8s
Usernames are limited to 1024 bytes, so no account can exist that is unable to log in. The username rides in the session cookie, which browsers and securecookie refuse past about 4 KB, leaving room for roughly 2000 bytes of username; the limit is about half that.

User.BeforeSave returns ErrUsernameTooLong when a whole User is created or saved. A byte-counting check constraint on users.username catches every other write, including a column update. The limit appears in the constant and in the struct tag; a test fails if they disagree.

Model: opus-5-5
2026-10-01 21:38:48 +02:00
clawbot b79e4649a1 Container sets its data directory's owner and mode itself (#353)
check / check (push) Canceled after 0s
Closes #340.

The image no longer sets `USER`. Its new `ENTRYPOINT`, `deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if missing, gives the directory and anything in it owned by another user to `webhooker` (UID 1000), sets the directory to `0750`, and runs the command as `webhooker` through `su-exec`. An empty root-owned bind mount, or data left by another UID, now works as mounted; the app never runs as root and is still PID 1. `CMD` is still `/app/webhooker`, so the `resetpw` commands are unchanged. Started with `--user`, the script only runs the command. It is in `/usr/local/bin`, not `/app`, which belongs to `webhooker`.

README: the UID 1000 ownership block, the upaas pre-deploy commands and the restore ownership step are gone; the upaas volume bullet names only the path.

- Judgement call: `su-exec` over `setpriv`: Alpine's small tool for this, needing only musl; busybox's `setpriv` cannot change user, and util-linux's adds `libcap-ng`.
- Deviation: `su-exec` is pinned by version (`0.2-r3`), not by hash; `ca-certificates` beside it is unpinned.
- Judgement call: each start reads every entry's owner but changes only entries owned by someone else.
- `docker exec` and the health check now run as root, since the image sets no `USER`.
- No automated test covers the script: the suite runs inside `docker build`, which cannot start a container.
- A missing host directory under upaas is sneak/upaas#235.

Model: opus-5-5
Reviewed-on: #353
Co-authored-by: clawbot <35+clawbot@noreply.example.org>
2026-09-29 13:05:16 +02:00
clawbot 1428154bbd Let a browser with cookies from an earlier database log in (closes #359)
check / check (push) Canceled after 0s
A new database brings a new session key. A browser still holding the
old session cookie got a 500 on a correct login: Session.Get returned
the cookie's decode error and the login handler answered it with a
500. Get now treats a cookie that does not decode as absent, and
logging in replaces it. gorilla/csrf already did the same for the
CSRF cookie.

A start that creates webhooker.db now logs "created a new, empty
database" at WARN with its path, shortly before the first-boot banner,
so an unexpectedly empty DATA_DIR is noticed.

The codec tests now decode through the store, since Get no longer
reports the codec's reason.

Model: opus-5-5
2026-09-29 12:58:44 +02:00
sneak 8ad2a86e4b Sneak/testdeploy (#356)
check / check (push) Successful in 11s
Reviewed-on: #356
2026-09-29 12:01:33 +02:00
sneak a891b726e5 Milestone: next into main (#342)
check / check (push) Successful in 9s
Reviewed-on: #342
2026-09-29 11:53:19 +02:00
clawbot ab63b5f777 Restrict /s/* to GET and HEAD (closes #169)
check / check (push) Successful in 3m37s
The static file server was attached with Mount, which registers every
method, so POST, PUT and DELETE on an asset were answered 200 with the
file. It is now registered for GET and HEAD only, inside a /s group
whose method-not-allowed handler answers 405 with Allow: GET, HEAD.
A method chi does not route at all, such as PROPFIND, still gets 405
from the top-level router, without Allow. The inverted test and the
README route table say the same.

Model: opus-5-5
2026-09-29 11:10:27 +02:00
44 changed files with 976 additions and 582 deletions
+2 -4
View File
@@ -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
View File
@@ -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.
+13 -13
View File
@@ -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"
@@ -88,7 +81,9 @@ RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static
# alpine:3.21, 2026-03-17
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk --no-cache add ca-certificates
# su-exec 0.2-r3 (Alpine 3.21), 2026-09-29: the entrypoint runs the app
# as webhooker with it.
RUN apk --no-cache add ca-certificates su-exec=0.2-r3
# Create non-root user
RUN addgroup -g 1000 -S webhooker && \
@@ -99,13 +94,17 @@ WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/bin/webhooker /app/webhooker
# Not under /app, which belongs to webhooker: this script runs as root.
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Create data directory for all SQLite databases (main app DB +
# per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker.
RUN mkdir -p /var/lib/webhooker
RUN chown -R webhooker:webhooker /app /var/lib/webhooker
USER webhooker
# No USER: the entrypoint starts as root to make the data directory
# webhooker's, then runs the app as webhooker.
EXPOSE 8080
@@ -124,4 +123,5 @@ ENV BIND_ADDRESS=0.0.0.0
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/app/webhooker"]
+3 -3
View File
@@ -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:
+103 -129
View File
@@ -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)
@@ -538,6 +533,12 @@ its Argon2id hash. There is no second account and no forgot-password
flow, so the banner and the reset command below are the only two ways
in.
A start that finds no `webhooker.db` in `DATA_DIR` also logs
`created a new, empty database` at `WARN`, with the file's path,
shortly before the banner. On a deployment that has run before, that
line means `DATA_DIR` was empty, most often because its volume is not
mounted.
#### Recovering a lost admin password
`webhooker resetpw` sets an existing account's password from the
@@ -552,8 +553,9 @@ printf '%s' "$NEW_PASSWORD" | \
DATA_DIR=/var/lib/webhooker webhooker resetpw admin
```
In a container it is the same binary, which the image sets as `CMD`
rather than `ENTRYPOINT`, so the whole command has to be given:
In a container it is the same binary. The image's `CMD` is
`/app/webhooker`, and a command given to `docker run` replaces all of
it, so the whole command has to be given:
```bash
docker run --rm -v webhooker-data:/var/lib/webhooker \
@@ -690,38 +692,22 @@ those three values rather than trusting the figure. Measured at 65s on
Docker 29.7.2.) A container `unhealthy` with `connection refused` in
its health log, or a published port that resets connections, is this.
The container runs as a non-root user (`webhooker`, UID 1000), exposes
port 8080, and includes a health check against
`/.well-known/healthcheck`. The `/var/lib/webhooker` volume holds all
SQLite databases: the main application database (`webhooker.db`), the
per-webhook event databases (`events-{uuid}.db`), and any archive
databases written by `database` targets (`archive-{uuid}.db`). Mount
this as a persistent volume to preserve data across container
restarts.
The app runs as a non-root user (`webhooker`, UID 1000), exposes port
8080, and includes a health check against `/.well-known/healthcheck`.
The `/var/lib/webhooker` volume holds all SQLite databases: the main
application database (`webhooker.db`), the per-webhook event databases
(`events-{uuid}.db`), and any archive databases written by `database`
targets (`archive-{uuid}.db`). Mount this as a persistent volume to
preserve data across container restarts.
**The bind-mounted directory must be owned by UID 1000, or the
container does not start.** Docker creates a `-v` source path that
does not exist yet as `root:root`, and the process runs as UID 1000,
so it cannot take its `DATA_DIR` lock:
```
webhooker: locking data directory /var/lib/webhooker: open
/var/lib/webhooker/webhooker.lock: permission denied
```
It exits non-zero at that point, before opening any database. Create
the directory ahead of the first `docker run`:
```bash
mkdir -p /path/to/data
chown 1000:1000 /path/to/data
chmod 750 /path/to/data
```
The same `chown` is what a restore needs — see step 4 of
[Restore](#restore). A **named volume** does not have this problem:
Docker copies the image's ownership onto a volume it initializes, and
the image creates `/var/lib/webhooker` owned by `webhooker`.
**The container sets its data directory's owner and mode itself
before the app starts**, so a host directory can be mounted as it is,
whoever owns it. The image's `ENTRYPOINT`,
`deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if
it is missing, gives the directory and anything in it that belongs to
another user to `webhooker`, sets the directory to `0750`, and only
then runs the app as `webhooker`. Started with `--user`, it changes
nothing and runs the app as that user.
**The file modes are not yours to set, and do not depend on the
directory.** `webhooker.db` holds target configuration in plaintext —
@@ -729,13 +715,10 @@ bearer tokens, API keys, Slack webhook URLs — along with the session
encryption key, so webhooker creates every SQLite file it owns `0600`:
each database and both of its `-wal` and `-shm` sidecars, across all
three tiers. Files an earlier build left `0644` are tightened when
they are opened. A `DATA_DIR` webhooker creates itself is `0750`, but
a bind mount supplies its own directory and Docker's default for one
it creates is `0755`; the `0600` files hold there regardless. The
`chmod 750` above is defence in depth — it stops other local users
listing the directory and learning your webhook UUIDs from the
`events-{uuid}.db` filenames — not the barrier protecting the
credentials.
they are opened. The directory's `0750` is defence in depth — it stops
other local users listing the directory and learning your webhook
UUIDs from the `events-{uuid}.db` filenames — not the barrier
protecting the credentials.
### Running under upaas
@@ -751,17 +734,6 @@ repository's `Dockerfile` and runs it. The app needs:
app name, port `8080`. Leave `PORT` unset: the image's health check
probes `8080`.
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
upaas bind-mounts the host path it is given and does not create it,
and the container does not start unless UID 1000 owns it (see
[Running with Docker](#running-with-docker)). Create it before the
first deploy:
```bash
mkdir -p /path/to/data
chown 1000:1000 /path/to/data
chmod 750 /path/to/data
```
- **Environment variables:**
- `WEBHOOKER_ENVIRONMENT=prod`
- `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
@@ -1020,12 +992,12 @@ done
`.backup` reads through the WAL and writes a single consistent file with
no sidecars of its own, so the destination is complete as it stands.
Two caveats. First, the runtime image is `alpine:3.21` with only
`ca-certificates` added — the `sqlite3` CLI is **not** in it, so run
this on the host against the volume path, or from a throwaway container
that mounts the volume. Second, each file is captured at its own
instant, so a webhook created or an event delivered between two files
being copied lands in one and not the other. If you need the whole set
coherent as of a single moment, stop the service.
`ca-certificates` and `su-exec` added — the `sqlite3` CLI is **not** in
it, so run this on the host against the volume path, or from a
throwaway container that mounts the volume. Second, each file is
captured at its own instant, so a webhook created or an event delivered
between two files being copied lands in one and not the other. If you
need the whole set coherent as of a single moment, stop the service.
Note that `sqlite3 <db> .dump` is **not** one of these procedures: it is
an export, it holds a read transaction open for as long as it runs, and
@@ -1079,21 +1051,11 @@ with any `-wal`/`-shm` beside it, or wait until there are none.
archive not opened since a crash. A copy salvaged from a crashed
instance has them for everything, and needs all of them.
4. **Fix ownership.** The container runs as the non-root `webhooker`
user, UID 1000 / GID 1000. Restored files must be owned by (or
writable by) that UID, and so must the directory itself — SQLite
creates the `-wal` and `-shm` sidecars beside the database, so a
writable file inside a directory it cannot write is not enough:
```bash
chown -R 1000:1000 /path/to/data
```
Restoring as `root` on the host and forgetting this step is the
usual way a restore fails.
5. Start the service. `AutoMigrate` runs against each restored database
as it is opened.
4. Start the service. The container gives the directory and the
restored files to the `webhooker` user before the app starts,
whoever restored them (see
[Running with Docker](#running-with-docker)). `AutoMigrate` runs
against each restored database as it is opened.
### Upgrades
@@ -1254,14 +1216,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:
@@ -1269,8 +1232,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)
@@ -1292,24 +1255,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
@@ -1472,7 +1436,7 @@ A registered user of the webhooker service.
| Field | Type | Description |
| ---------- | -------- | ----------- |
| `id` | UUID | Primary key |
| `username` | string | Unique login name |
| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie |
| `password` | string | Argon2id hash (never exposed via API) |
**Relations:** Has many Webhooks. Has many APIKeys.
@@ -2412,14 +2376,14 @@ Removing either cap fails 14 subtests.
`internal/middleware/logbound_test.go` and
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
at each of these — 1 KB at `invalid password`, whose accounts are
shared with the successful-login line, where a username past 4 KB
overflows the session cookie and answers 500 before that line is
written — through both handlers, and through seven fills: plain text
as the baseline, and then the quotation mark, backslash, tab, newline,
C0 control and astral non-printable, six characters the wider of the
two handlers spends more on than the client spent sending them. Every
case holds each line to the 2,560-byte ceiling. That per-line ceiling
at each of these — just under 1 KB at `invalid password`, whose
accounts are shared with the successful-login line and so must stay
within the 1024-byte username limit — through both handlers, and
through seven fills: plain text as the baseline, and then the
quotation mark, backslash, tab, newline, C0 control and astral
non-printable, six characters the wider of the two handlers spends
more on than the client spent sending them. Every case holds each
line to the 2,560-byte ceiling. That per-line ceiling
is what the figure above states, and every row establishes it.
Three of the sites go further and bound the whole flood's output — the
@@ -2721,7 +2685,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
@@ -2788,6 +2752,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/
@@ -2881,8 +2847,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
@@ -3071,7 +3036,11 @@ check, see [The login endpoint](#the-login-endpoint).
- Prometheus metrics behind basic auth
- Static assets embedded in binary (no filesystem access needed at
runtime)
- Container runs as non-root user (UID 1000)
- The app runs as the non-root `webhooker` user (UID 1000) in the
container. The image sets no `USER`, so these run as root: the
`ENTRYPOINT` script, which sets the data directory's owner and mode
before the app starts; the image's health check; and `docker exec`,
unless given `--user`
- GORM soft deletes on every entity that carries `BaseModel`, which is
all of them but `Setting` (data preserved for audit)
@@ -3190,18 +3159,21 @@ 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)).
3. **Runtime stage** (`alpine:3.21`) — copies the static binary,
creates the `/var/lib/webhooker` directory for all SQLite databases,
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
and includes a health check against `/.well-known/healthcheck`.
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
a health check against `/.well-known/healthcheck`. It sets no
`USER`: the `ENTRYPOINT` script starts as root, sets the data
directory's owner and mode, and runs the app as the non-root
`webhooker` user (UID 1000) through `su-exec`.
The lint stage invokes `golangci-lint` directly rather than `make lint`:
it is already the pinned linter image, and `make lint` builds
@@ -3280,3 +3252,5 @@ MIT
## Author
[@sneak](https://sneak.berlin)
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the image's ENTRYPOINT. A bind-mounted
# data directory keeps its owner from the host, often root, and the app
# could not write to it. Started as root, this creates DATA_DIR if
# needed, gives it and everything in it to webhooker, sets its mode, and
# runs the command as webhooker, so the app never runs as root. Started
# as another user, it only runs the command.
set -eu
main() {
if [ "$(id -u)" != 0 ]; then
exec "$@"
fi
dir="${DATA_DIR:-/var/lib/webhooker}"
mkdir -p "$dir"
find "$dir" ! -user webhooker -exec chown -h webhooker:webhooker {} +
chmod 750 "$dir"
exec su-exec webhooker "$@"
}
main "$@"
@@ -3,6 +3,8 @@ package database_test
import (
"bytes"
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
@@ -83,3 +85,37 @@ func TestFirstBoot_PrintsTheAdminPasswordAsABanner(t *testing.T) {
t, ok, "the printed password must open the seeded account",
)
}
// TestNewDatabase_IsLoggedWithItsPath is the log half of
// https://git.eeqj.de/sneak/webhooker/issues/359. A DATA_DIR that is
// unexpectedly empty boots exactly like a first start, so the start
// that creates the database must say so, and where. Opening that
// database again must not.
func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
t.Parallel()
dir := t.TempDir()
open := func() string {
var out bytes.Buffer
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
require.NoError(t, err)
require.NoError(t, db.Close())
return out.String()
}
const created = `level=WARN msg="created a new, empty database"`
first := open()
second := open()
assert.Contains(
t, first,
created+" path="+filepath.Join(dir, database.MainDBFileName),
)
assert.NotContains(
t, second, created, "an existing database is not new",
)
}
+13 -1
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
@@ -199,6 +200,12 @@ func (d *Database) connectTo(dataDir string) error {
// Construct the main application database path inside DATA_DIR.
dbPath := filepath.Join(dataDir, MainDBFileName)
// Checked before opening, which creates the file. A DATA_DIR that
// is unexpectedly empty -- its volume not mounted, say -- looks
// exactly like a first start, so a new database is a warning.
_, statErr := os.Stat(dbPath)
created := errors.Is(statErr, fs.ErrNotExist)
// Opened through OpenSQLite so this handle carries the same WAL
// journaling, busy timeout, immediate-transaction locking, and pool
// bounds as every other database file. See sqlite_open.go.
@@ -229,7 +236,12 @@ func (d *Database) connectTo(dataDir string) error {
}
d.db = db
d.log.Info("connected to database", "path", dbPath)
if created {
d.log.Warn("created a new, empty database", "path", dbPath)
} else {
d.log.Info("connected to database", "path", dbPath)
}
// Run migrations
return d.migrate()
+47 -2
View File
@@ -1,13 +1,58 @@
package database
import (
"errors"
"fmt"
"gorm.io/gorm"
)
// MaxUsernameBytes is the longest username, in bytes, that a user may
// have. The same number appears in the check constraint on
// User.Username, because a struct tag cannot reference a constant.
//
// A login stores the username in the session cookie, and both
// securecookie and browsers refuse a cookie value past about 4096
// bytes. That value is the session base64-encoded twice, so it holds
// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature,
// timestamp and the session's other values take about 270 of those: a
// username longer than about 2030 bytes can never log in. The limit is
// about half that, so the session can carry more values later without
// locking out an account whose username is already at the limit.
const MaxUsernameBytes = 1024
// ErrUsernameTooLong is returned when a user is saved with a username
// longer than MaxUsernameBytes.
var ErrUsernameTooLong = errors.New("username is too long")
// User represents a user of the webhooker service
//
//nolint:lll // a struct tag cannot wrap
type User struct {
BaseModel
Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations
Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"apiKeys,omitempty"`
}
// BeforeSave rejects a username longer than MaxUsernameBytes when a whole
// User is created or saved, so those calls get ErrUsernameTooLong rather
// than the database's constraint error. A column update such as
// Update("username", ...) is caught only by the check constraint, as is
// any path that writes the table without this model.
func (u *User) BeforeSave(_ *gorm.DB) error {
if len(u.Username) > MaxUsernameBytes {
return fmt.Errorf(
"%w: %d bytes, limit is %d",
ErrUsernameTooLong,
len(u.Username),
MaxUsernameBytes,
)
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package database_test
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// usernameAtLimit is exactly MaxUsernameBytes long, built from a
// two-byte character. A check that counted characters rather than bytes
// would see half the length and let the one-byte-longer name through.
func usernameAtLimit() string {
return strings.Repeat("é", database.MaxUsernameBytes/2)
}
func TestUserCreate_RejectsOverlongUsername(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
err := db.Create(&database.User{
Username: usernameAtLimit() + "x",
Password: "hash",
}).Error
require.ErrorIs(t, err, database.ErrUsernameTooLong)
}
func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
require.NoError(t, db.Create(&database.User{
Username: usernameAtLimit(),
Password: "hash",
}).Error)
}
// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw
// SQL, as a path that bypassed User.BeforeSave would, so only the
// table's check constraint stands between it and an over-long
// username. Accepting the name at the limit and refusing the next byte
// also pins the constraint's number to MaxUsernameBytes.
func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)"
require.NoError(t, db.Exec(
insert, uuid.New().String(), usernameAtLimit(), "hash",
).Error)
err := db.Exec(
insert, uuid.New().String(), usernameAtLimit()+"x", "hash",
).Error
require.Error(t, err)
assert.Contains(t, err.Error(), "CHECK constraint failed")
}
+5 -23
View File
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -165,11 +165,7 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password)
if err != nil {
h.log.Error("failed to verify password", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to verify password", err)
return user, err
}
@@ -241,24 +237,14 @@ func (h *Handlers) createAuthenticatedSession(
) error {
oldSess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to get session", err)
return err
}
sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil {
h.log.Error(
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to regenerate session", err)
return err
}
@@ -267,11 +253,7 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess)
if err != nil {
h.log.Error("failed to save session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to save session", err)
return err
}
+30
View File
@@ -453,3 +453,33 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
"the issued cookie must carry an authenticated session",
)
}
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
// database.MaxUsernameBytes still fits in the session cookie. Past
// what the cookie can carry, a correct login answers 500.
func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
username := strings.Repeat("a", database.MaxUsernameBytes)
hash, err := database.HashPassword(operatorPassword)
require.NoError(t, err)
require.NoError(t, db.DB().Create(&database.User{
Username: username,
Password: hash,
}).Error)
w := submitLogin(h, sharedProxyPeer, username, operatorPassword)
assert.Equal(t, http.StatusSeeOther, w.Code)
}
+7 -9
View File
@@ -105,9 +105,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -124,14 +122,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook,
) {
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
@@ -173,7 +171,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return nil, false
}
@@ -195,7 +193,7 @@ func (h *Handlers) queueReplay(
)
if err != nil {
h.serverError(
w, "failed to count in-flight deliveries", err,
w, r, "failed to count in-flight deliveries", err,
)
return
@@ -212,7 +210,7 @@ func (h *Handlers) queueReplay(
err = webhookDB.
First(&event, "id = ?", original.EventID).Error
if err != nil {
h.serverError(w, "failed to load event for replay", err)
h.serverError(w, r, "failed to load event for replay", err)
return
}
@@ -222,7 +220,7 @@ func (h *Handlers) queueReplay(
)
if err != nil {
h.serverError(
w, "failed to create replay delivery", err,
w, r, "failed to create replay delivery", err,
)
return
+53
View File
@@ -0,0 +1,53 @@
package handlers_test
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
// which cannot render answers with the status it was reporting, as
// plain text, and is not attempted again: a page whose own render
// fails reaches the error page, and the error page failing as well
// ends there with the 500.
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// .Status is an int, so asking it for a field fails the render.
failing := `{{.Status.Missing}}`
h.AddTemplateForTest("error.html", template.Must(
template.New("error").Parse(failing),
))
h.AddTemplateForTest("failing.html", template.Must(
template.New("failing").Parse(`{{.Data.Missing}}`),
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
w := httptest.NewRecorder()
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "Not Found\n", w.Body.String())
w = httptest.NewRecorder()
h.RenderTemplateForTest(w, req, "failing.html", 0)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
}
+5 -5
View File
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
// steered by a client.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
eventID string,
) {
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
if err != nil {
h.serverError(w, "failed to read event body", err)
h.serverError(w, r, "failed to read event body", err)
return
}
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
// row and the whole body is served, or it does not and the
// response is a clean 404.
if !found {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
+8 -8
View File
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
// alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
webhookDB, webhook.ID, eventID.String(),
)
if err != nil {
h.serverError(w, "failed to load event to resubmit", err)
h.serverError(w, r, "failed to load event to resubmit", err)
return
}
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
// A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed.
if !found {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
// inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID)
if err != nil {
h.serverError(w, "failed to query targets", err)
h.serverError(w, r, "failed to query targets", err)
return
}
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
targets,
)
if err != nil {
h.serverError(w, "failed to store resubmitted event", err)
h.serverError(w, r, "failed to store resubmitted event", err)
return
}
+12 -2
View File
@@ -1,9 +1,11 @@
package handlers
import (
"context"
"html/template"
"log/slog"
"net/http"
"net/http/httptest"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -63,12 +65,20 @@ func (s *Handlers) LoadEventLogViewsForTest(
page int,
) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries(
w, webhook, nil, page,
w, newRequestForTest(), webhook, nil, page,
)
return views
}
// newRequestForTest is the request the helpers here pass on for
// callers that have none: it is used only to render the error page.
func newRequestForTest() *http.Request {
return httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
}
// AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a
// template of its own.
@@ -122,5 +132,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter,
expiry string,
) (string, error) {
return s.buildDatabaseTargetConfig(w, expiry)
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
}
+84 -19
View File
@@ -135,6 +135,7 @@ func New(
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
}
lc.Append(fx.Hook{
@@ -146,6 +147,15 @@ func New(
return s, nil
}
// HandleErrorPage returns a handler that answers every request with
// the error page for status. The router uses it for unknown paths and
// the CSRF middleware for a refused form.
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, status)
}
}
func (s *Handlers) respondJSON(
w http.ResponseWriter,
_ *http.Request,
@@ -163,15 +173,72 @@ func (s *Handlers) respondJSON(
}
}
// serverError logs an error and sends a 500 response.
// serverError logs an error and answers with the 500 error page.
func (s *Handlers) serverError(
w http.ResponseWriter, msg string, err error,
w http.ResponseWriter, r *http.Request, msg string, err error,
) {
s.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
}
// renderError answers with status and the error page: the normal
// layout, one fixed line explaining the status, and a link back to the
// webhook list, or to sign-in when nobody is signed in.
//
// It renders the page itself rather than through renderTemplate,
// whose own failure comes here. If the error page cannot render
// either, the answer is the same status in plain text: never a second
// attempt, and never a different status.
func (s *Handlers) renderError(
w http.ResponseWriter,
r *http.Request,
status int,
) {
data := s.pageData(r, map[string]any{
"Status": status,
"StatusText": http.StatusText(status),
"Message": errorPageText(status),
})
var buf bytes.Buffer
err := s.templates["error.html"].Execute(&buf, data)
if err != nil {
s.log.Error("failed to render error page", "error", err)
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
s.log.Error("failed to write error page", "error", err)
}
}
// errorPageText is the line the error page shows for status. It is
// fixed per status, so the page tells the reader no more than the
// plain-text answers it replaced did.
func errorPageText(status int) string {
switch status {
case http.StatusBadRequest:
return "The request could not be read."
case http.StatusForbidden:
return "The request was refused. If it came from a form " +
"left open for a long time, reload the page and try " +
"again."
case http.StatusNotFound:
return "There is nothing here. It may have been deleted, " +
"or the address may be wrong."
case http.StatusServiceUnavailable:
return "The server is busy. Please try again in a moment."
default: // http.StatusInternalServerError
return "Something went wrong on the server. Please try " +
"again."
}
}
// UserInfo represents user information for templates
@@ -224,14 +291,17 @@ func (s *Handlers) renderTemplate(
"template not found",
"template", pageTemplate,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
return
}
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
}
// pageData adds the fields the shared layout renders to a page's own
// data.
func (s *Handlers) pageData(r *http.Request, data any) any {
userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r)
@@ -245,19 +315,16 @@ func (s *Handlers) renderTemplate(
m["User"] = userInfo
m["CSRFToken"] = csrfToken
m["Version"] = version
s.executeTemplate(w, tmpl, m)
return
return m
}
wrapper := templateDataWrapper{
return templateDataWrapper{
User: userInfo,
CSRFToken: csrfToken,
Version: version,
Data: data,
}
s.executeTemplate(w, tmpl, wrapper)
}
// executeTemplate renders the template into a buffer and writes to
@@ -270,6 +337,7 @@ func (s *Handlers) renderTemplate(
// this reason.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
r *http.Request,
tmpl *template.Template,
data any,
) {
@@ -280,10 +348,7 @@ func (s *Handlers) executeTemplate(
s.log.Error(
"failed to execute template", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
return
}
+6 -2
View File
@@ -307,10 +307,14 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500",
)
assert.Equal(
t, "Internal server error\n", w.Body.String(),
assert.NotContains(
t, w.Body.String(), partialPageMarker,
"the response must carry no part of the aborted page",
)
assert.Contains(
t, w.Body.String(), "500 Internal Server Error",
"a failed render must answer with the error page",
)
}
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
+3 -5
View File
@@ -339,11 +339,9 @@ const storedUserPassword = "correct-horse-battery-staple"
// storedFillBytes is the raw length of the client-chosen value in
// those accounts' usernames. It is well past the 512-byte field
// budget, so the line is still truncated, but short enough that the
// session cookie a successful login writes stays inside
// securecookie's 4 KB limit: the cookie is written BEFORE the
// "user logged in" line, so an 8 KB username answers 500 and never
// reaches it.
const storedFillBytes = 1024
// whole username, markers and fill name included, stays within
// database.MaxUsernameBytes.
const storedFillBytes = 960
// storedFill builds a username fill of storedFillBytes raw bytes out
// of repetitions of ch, with both markers at its far end.
+16 -28
View File
@@ -1,7 +1,6 @@
package handlers
import (
"context"
"net/http"
"github.com/go-chi/chi"
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
successMessage, errorMessage, handled := h.applyPasswordChange(
r.Context(),
w,
r,
sessionUsername,
// PostFormValue, not FormValue: the credential must
// come from the body, never from the query string.
@@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
// applyPasswordChange verifies the current password and, on success,
// persists a fresh hash for the user, reusing the same helpers that
// bootstrap the admin user. It returns the success and error messages
// to display on the profile page. On an internal failure it writes a
// 500 response itself and returns handled=false, signalling the caller
// to display on the profile page. On an internal failure it writes the
// error page itself and returns handled=false, signalling the caller
// to stop without re-rendering the page.
func (h *Handlers) applyPasswordChange(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
username, currentPassword, newPassword, confirmPassword string,
) (string, string, bool) {
// This endpoint verifies one password and hashes another, at
@@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange(
// endpoint uses. The bound is per hash, not per endpoint: leaving
// this path outside it would leave a hole in it. The slot is held
// across both hashes.
release, ok := h.mw.BeginPasswordVerification(ctx)
release, ok := h.mw.BeginPasswordVerification(r.Context())
if !ok {
h.log.Warn("password verification capacity exhausted")
http.Error(
w,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
h.renderError(w, r, http.StatusServiceUnavailable)
return "", "", false
}
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
).First(&user).Error
if err != nil {
h.serverError(
w, "failed to load user for password change", err,
w, r, "failed to load user for password change", err,
)
return "", "", false
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
currentPassword, user.Password,
)
if err != nil {
h.serverError(w, "failed to verify password", err)
h.serverError(w, r, "failed to verify password", err)
return "", "", false
}
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
hashedPassword, err := database.HashPassword(newPassword)
if err != nil {
h.serverError(w, "failed to hash new password", err)
h.serverError(w, r, "failed to hash new password", err)
return "", "", false
}
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
"password", hashedPassword,
).Error
if err != nil {
h.serverError(w, "failed to update password", err)
h.serverError(w, r, "failed to update password", err)
return "", "", false
}
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
) (string, string, bool) {
requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return "", "", false
}
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
// unexpected retrieval error.
sess, err := h.session.Get(r)
if err != nil {
h.serverError(w, "failed to get session", err)
h.serverError(w, r, "failed to get session", err)
return "", "", false
}
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUsername, ok := h.session.GetUsername(sess)
if !ok {
h.log.Error("authenticated session missing username")
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.renderError(w, r, http.StatusInternalServerError)
return "", "", false
}
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUserID, ok := h.session.GetUserID(sess)
if !ok {
h.log.Error("authenticated session missing user ID")
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.renderError(w, r, http.StatusInternalServerError)
return "", "", false
}
// Only allow users to act on their own profile.
if requestedUsername != sessionUsername {
http.Error(w, "Forbidden", http.StatusForbidden)
h.renderError(w, r, http.StatusForbidden)
return "", "", false
}
+4 -2
View File
@@ -126,7 +126,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
var sess *session.Session
app := newTestApp(t, &log, &cfg, &sess)
var h *handlers.Handlers
app := newTestApp(t, &log, &cfg, &sess, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
@@ -137,7 +139,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) {
r.Use(mw.CSRF())
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
r.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true
+37 -59
View File
@@ -149,13 +149,7 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
"user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error
if err != nil {
h.log.Error(
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to list webhooks", err)
return
}
@@ -249,9 +243,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -311,7 +303,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
err := h.commitWebhook(webhook)
if err != nil {
h.serverError(w, "failed to create webhook", err)
h.serverError(w, r, "failed to create webhook", err)
return
}
@@ -388,7 +380,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -475,7 +467,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -510,7 +502,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -519,9 +511,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -575,7 +565,7 @@ func (h *Handlers) applyWebhookEdit(
err := h.db.DB().Save(webhook).Error
if err != nil {
h.serverError(w, "failed to update webhook", err)
h.serverError(w, r, "failed to update webhook", err)
return
}
@@ -605,7 +595,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -632,7 +622,7 @@ func (h *Handlers) deleteWebhookResources(
// be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook)
if err != nil {
h.serverError(w, "failed to delete webhook", err)
h.serverError(w, r, "failed to delete webhook", err)
return
}
@@ -658,7 +648,7 @@ func (h *Handlers) deleteWebhookResources(
// redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it.
h.serverError(
w, "failed to delete webhook event database", err,
w, r, "failed to delete webhook event database", err,
)
return
@@ -802,7 +792,7 @@ func (h *Handlers) ownedWebhook(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, false
}
@@ -824,7 +814,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// Without the map every delivery renders through a
// zero redactor, so failing the page is the only
// safe answer.
h.serverError(w, "failed to load targets", err)
h.serverError(w, r, "failed to load targets", err)
return
}
@@ -832,7 +822,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries(
w, webhook, targets, page,
w, r, webhook, targets, page,
)
if !ok {
return
@@ -942,6 +932,7 @@ func (h *Handlers) parsePage(r *http.Request) int {
// caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
page int,
@@ -955,7 +946,7 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(
w, "failed to get webhook database", err,
w, r, "failed to get webhook database", err,
)
return nil, 0, false
@@ -992,7 +983,7 @@ func (h *Handlers) loadEventsWithDeliveries(
)
if err != nil {
h.serverError(
w, "failed to load delivery attempts", err,
w, r, "failed to load delivery attempts", err,
)
return nil, 0, false
@@ -1001,7 +992,7 @@ func (h *Handlers) loadEventsWithDeliveries(
resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil {
h.serverError(
w, "failed to count event resubmissions", err,
w, r, "failed to count event resubmissions", err,
)
return nil, 0, false
@@ -1224,7 +1215,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1233,9 +1224,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -1251,7 +1240,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
err = h.db.DB().Create(entrypoint).Error
if err != nil {
h.serverError(w, "failed to create entrypoint", err)
h.serverError(w, r, "failed to create entrypoint", err)
return
}
@@ -1282,7 +1271,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1291,9 +1280,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -1364,7 +1351,7 @@ func (h *Handlers) processTargetCreate(
err = h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, "failed to create target", err)
h.serverError(w, r, "failed to create target", err)
return
}
@@ -1458,7 +1445,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, in.Expiry)
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
case database.TargetTypeLog:
return "", nil
default:
@@ -1508,7 +1495,7 @@ func (h *Handlers) buildHTTPTargetConfig(
return "", err
}
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
@@ -1530,7 +1517,7 @@ func (h *Handlers) buildSlackTargetConfig(
return "", err
}
return marshalTargetConfig(w, delivery.SlackTargetConfig{
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
}
@@ -1584,16 +1571,14 @@ func (h *Handlers) validateTargetURL(
// marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot.
func marshalTargetConfig(
func (h *Handlers) marshalTargetConfig(
w http.ResponseWriter,
r *http.Request,
cfg any,
) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to encode target config", err)
return "", err
}
@@ -1609,6 +1594,7 @@ func marshalTargetConfig(
// expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter,
r *http.Request,
expiry string,
) (string, error) {
expiry = strings.TrimSpace(expiry)
@@ -1627,8 +1613,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
return "", err
}
return marshalTargetConfig(
w, map[string]any{"expiry": expiry},
return h.marshalTargetConfig(
w, r, map[string]any{"expiry": expiry},
)
}
@@ -1682,7 +1668,7 @@ func (h *Handlers) deleteChildResource(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1692,11 +1678,7 @@ func (h *Handlers) deleteChildResource(
childID, webhook.ID,
).Delete(model)
if result.Error != nil {
h.log.Error(errMsg, "error", result.Error)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, errMsg, result.Error)
return
}
@@ -1786,18 +1768,14 @@ func (h *Handlers) toggleChildResource(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
err = toggleFn(webhook.ID, childID)
if err != nil {
h.log.Error(errMsg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, errMsg, err)
return
}
+3 -5
View File
@@ -88,9 +88,7 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -157,7 +155,7 @@ func (h *Handlers) applyTargetEdit(
err = h.db.DB().Save(target).Error
if err != nil {
h.serverError(w, "failed to update target", err)
h.serverError(w, r, "failed to update target", err)
return
}
@@ -220,7 +218,7 @@ func (h *Handlers) ownedTarget(
chi.URLParam(r, "targetID"), webhook.ID,
).First(&target).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, nil, false
}
+16 -3
View File
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
headersJSON, err := json.Marshal(r.Header)
if err != nil {
h.serverError(w, "failed to serialize headers", err)
h.receiverError(w, "failed to serialize headers", err)
return
}
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
if err != nil {
h.serverError(w, "failed to query targets", err)
h.receiverError(w, "failed to query targets", err)
return
}
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
targets,
)
if err != nil {
h.serverError(w, "failed to store webhook event", err)
h.receiverError(w, "failed to store webhook event", err)
return
}
@@ -204,6 +204,19 @@ func (h *Handlers) createAndDeliverEvent(
h.finishWebhookResponse(w, event, entrypoint, tasks)
}
// receiverError logs an error and answers the sender with a plain-text
// 500. The receiver's answers are for programs, so it never sends the
// error page the web UI uses.
func (h *Handlers) receiverError(
w http.ResponseWriter, msg string, err error,
) {
h.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// eventSource carries the fields a new event is built from. The
// receiver fills it from the live request; the resubmit handler fills
// it from a stored event. Both then go through createAndFanOut, so an
+5 -3
View File
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
// key to sign a CSRF cookie and validates a masked token submitted via
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
// token receive a 403 Forbidden response.
// token are logged and answered by forbidden, which must write the 403.
//
// The middleware detects the client-facing transport protocol
// per-request via reqtls.IsTLS, the single TLS predicate the session
@@ -36,7 +36,9 @@ func CSRFToken(r *http.Request) string {
// Two gorilla/csrf instances are maintained — one with Secure cookies
// (for TLS) and one without (for plaintext HTTP) — because the
// csrf.Secure option is set at creation time, not per-request.
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
func (m *Middleware) CSRF(
forbidden http.Handler,
) func(http.Handler) http.Handler {
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an
@@ -57,7 +59,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
"remote_addr", r.RemoteAddr,
"reason", csrf.FailureReason(r),
)
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
forbidden.ServeHTTP(w, r)
})
key := m.session.GetKey()
+15 -9
View File
@@ -18,6 +18,12 @@ import (
// csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf"
// forbidden stands in for the error page the server hands CSRF to
// answer a refused request with.
func forbidden(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}
// csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies.
func csrfGetToken(
@@ -98,7 +104,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
var gotToken string
handler := m.CSRF()(http.HandlerFunc(
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r)
},
@@ -120,7 +126,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -152,7 +158,7 @@ func csrfPOSTWithoutTokenTest(
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
@@ -209,7 +215,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
@@ -265,7 +271,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
var called bool
handler := m.CSRF()(http.HandlerFunc(
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
@@ -328,7 +334,7 @@ func csrfTookStrictPath(
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
newReq := func(method string) *http.Request {
r := httptest.NewRequestWithContext(
@@ -477,7 +483,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -517,7 +523,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -562,7 +568,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
+3 -1
View File
@@ -260,7 +260,9 @@ func logSites() map[string]logSite {
) http.Handler {
t.Helper()
return m.CSRF()(unreachable(t))
return m.CSRF(http.HandlerFunc(forbidden))(
unreachable(t),
)
},
send: postNoToken,
wantStatus: http.StatusForbidden,
+155
View File
@@ -0,0 +1,155 @@
package server_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
)
// The link back the error page offers: to the webhook list for a
// signed-in user, to sign-in for anyone else.
const (
backToWebhooks = `<a href="/sources" class="btn-secondary">` +
`Back to webhooks</a>`
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
`Sign in</a>`
)
// assertErrorPage checks that w is the error page for status, in the
// normal layout, offering link.
func assertErrorPage(
t *testing.T,
w *httptest.ResponseRecorder,
status int,
link string,
) {
t.Helper()
body := w.Body.String()
assert.Equal(t, status, w.Code)
assert.Equal(
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
)
assert.Contains(t, body, `<nav class="app-bar"`)
assert.Contains(
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
)
assert.Contains(t, body, link)
}
func TestErrorPage_DeletedWebhook(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Delete(wh).Error)
w := env.get("/source/"+wh.ID, cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_DeletedTarget(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID)
require.NoError(t, env.db.DB().Delete(tgt).Error)
w := env.get(
"/source/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_UnknownPath(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
assertErrorPage(
t, env.get("/no-such-page", nil),
http.StatusNotFound, backToSignIn,
)
// Outside every route group there is no form token, so the
// page leaves out the logout form rather than offer one that
// would be refused.
w := env.get("/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
// Inside a route group the page has a token, and logout works.
wh := env.seedWebhook(t, userID)
w = env.get("/source/"+wh.ID+"/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
}
func TestErrorPage_BadCSRFToken(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
form := url.Values{}
form.Set("username", "someone")
form.Set("password", "irrelevant")
form.Set("csrf_token", "not-a-token")
assertErrorPage(
t, env.post("/pages/login", form, nil),
http.StatusForbidden, backToSignIn,
)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
edit := url.Values{}
edit.Set("name", "renamed")
assertErrorPage(
t, env.post("/source/"+wh.ID+"/edit", edit, cookies),
http.StatusForbidden, backToWebhooks,
)
}
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
// the web UI only: a sender posting to an entrypoint that does not
// exist still gets the plain-text answer.
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
t.Parallel()
// newTestEnv leaves the receiver rate limit at zero, which
// refuses every request before it reaches the receiver.
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: 10,
})
w := env.post("/webhook/no-such-entrypoint", url.Values{}, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "404 page not found\n", w.Body.String())
}
+29 -7
View File
@@ -46,6 +46,14 @@ const requestTimeout = 60 * time.Second
// server's router.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
// An unknown path gets the error page. Registered before the
// global middleware, because chi wraps a not-found handler in the
// middleware already on its router, which would then run twice.
// The route groups below wrap it in their own middleware the same
// way; running theirs twice is harmless.
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
s.setupGlobalMiddleware()
s.setupRoutes()
}
@@ -92,11 +100,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.
})
@@ -136,7 +158,7 @@ func (s *Server) setupPageRoutes() {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
// The login POST carries no pre-emptive rate limiter. Behind
@@ -157,7 +179,7 @@ func (s *Server) setupUserRoutes() {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleProfile())
@@ -172,7 +194,7 @@ func (s *Server) setupSourceRoutes() {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceList())
@@ -184,7 +206,7 @@ func (s *Server) setupSourceRoutes() {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceDetail())
+108 -19
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"testing"
@@ -220,9 +221,21 @@ func (e *testEnv) csrfFrom(
// out of the markup has to be unescaped before it is submitted.
token := html.UnescapeString(match[1])
combined := make([]*http.Cookie, 0, len(cookies))
combined = append(combined, cookies...)
combined = append(combined, w.Result().Cookies()...)
// A cookie the page sets replaces the one of the same name, as in
// a browser. Sent both, the server would read the first, older one.
set := w.Result().Cookies()
combined := make([]*http.Cookie, 0, len(cookies)+len(set))
for _, c := range cookies {
replaced := slices.ContainsFunc(set, func(n *http.Cookie) bool {
return n.Name == c.Name
})
if !replaced {
combined = append(combined, c)
}
}
combined = append(combined, set...)
return token, combined
}
@@ -396,13 +409,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 +432,7 @@ func TestStaticServesEveryMethod(t *testing.T) {
http.MethodPost,
http.MethodPut,
http.MethodDelete,
"PROPFIND",
} {
t.Run(method, func(t *testing.T) {
t.Parallel()
@@ -428,18 +444,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 {
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")
return
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",
)
}
assert.Equal(t, body, w.Body.Bytes(),
"the asset itself is returned")
})
}
}
@@ -591,6 +627,59 @@ func TestPagesLogin_CorrectPasswordSurvivesASpentBudget(
)
}
// TestPagesLogin_CookiesFromAnEarlierDatabase is
// https://git.eeqj.de/sneak/webhooker/issues/359. A new database
// brings a new session key, and the operator's browser still holds
// the session and CSRF cookies signed with the old one. Logging in
// must work as from a fresh browser and leave cookies the new key
// accepts.
func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
t.Parallel()
const (
username = "operator"
password = "correct-horse-battery-staple"
)
earlier := newTestEnv(t)
earlierID, _ := earlier.seedUser(t, username, password)
_, stale := earlier.csrfFrom(t, "/pages/login", nil)
stale = append(stale, earlier.authCookies(t, earlierID, username)...)
env := newTestEnv(t)
env.seedUser(t, username, password)
token, cookies := env.csrfFrom(t, "/pages/login", stale)
form := url.Values{}
form.Set("csrf_token", token)
form.Set("username", username)
form.Set("password", password)
w := env.post("/pages/login", form, cookies)
require.Equal(
t, http.StatusSeeOther, w.Code,
"a session cookie from another key must not fail the login",
)
// The response deletes the old session cookie and then sets the
// new one; a browser keeps the last.
var fresh *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == session.SessionName {
fresh = c
}
}
require.NotNil(t, fresh, "login must set a session cookie")
assert.Equal(
t, "/sources",
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
"the new session cookie must authenticate",
)
}
// --- /user/{username} group ---
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
+3 -3
View File
@@ -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()
+8 -7
View File
@@ -19,8 +19,8 @@ import (
)
// The tests below exercise the securecookie codecs underneath the
// store and nothing else: Session.Get only decodes, so no server-side
// expiry check takes part in the result. They exist because
// store and nothing else: they decode through the store itself, so no
// server-side expiry check takes part in the result. They exist because
// NewCookieStore gives its codecs a 30-day max age that assigning
// store.Options does not override, which would let the codec accept a
// cookie weeks past the cap the cookie attribute advertises.
@@ -75,10 +75,11 @@ func restamp(
return base64.URLEncoding.EncodeToString(payload)
}
// decodeCookie feeds value back through the store's decode path.
// decodeCookie feeds value back through the store's decode path. It
// asks the store rather than Session.Get, which treats a cookie that
// does not decode as absent and so hides the codec's reason.
func decodeCookie(
t *testing.T,
s *session.Session,
value string,
) (*sessions.Session, error) {
t.Helper()
@@ -94,7 +95,7 @@ func decodeCookie(
SameSite: http.SameSiteLaxMode,
})
sess, err := s.Get(req)
sess, err := session.NewStore(testKey()).Get(req, session.SessionName)
require.NotNil(t, sess)
return sess, err
@@ -105,7 +106,7 @@ func TestCodec_AcceptsCookieInsideAbsoluteCap(t *testing.T) {
s := testSession(t)
sess, err := decodeCookie(t, s, restamp(
sess, err := decodeCookie(t, restamp(
t,
issuedCookie(t, s),
time.Now().Add(-(testAbsoluteMaxAge-time.Hour)),
@@ -126,7 +127,7 @@ func TestCodec_RejectsCookiePastAbsoluteCap(t *testing.T) {
s := testSession(t)
sess, err := decodeCookie(t, s, restamp(
sess, err := decodeCookie(t, restamp(
t,
issuedCookie(t, s),
time.Now().Add(-(testAbsoluteMaxAge+time.Hour)),
+13 -1
View File
@@ -224,10 +224,22 @@ func New(
}
// Get retrieves a session for the request.
//
// A session cookie that does not decode -- one signed with an earlier
// session key, say, because the database was made anew -- is treated
// as absent: the caller gets a new, empty session and no error, and
// the next save replaces the cookie.
func (s *Session) Get(
r *http.Request,
) (*sessions.Session, error) {
return s.store.Get(r, SessionName)
sess, err := s.store.Get(r, SessionName)
if sess == nil {
return nil, err
}
// For a cookie that does not decode, gorilla/sessions returns a
// new, empty session alongside the error that is dropped here.
return sess, nil
}
// GetKey returns the raw 32-byte authentication key used for
Executable
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
-104
View File
@@ -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 "$@"
+1
View File
@@ -28,6 +28,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/assets"
go test -v -race -timeout 90s ./...
}
-1
View File
@@ -1 +0,0 @@
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
-92
View File
@@ -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
}
+15
View File
@@ -0,0 +1,15 @@
{{template "base" .}}
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-4xl mx-auto px-6 py-12">
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
<p class="text-gray-600 mb-6">{{.Message}}</p>
{{if .User}}
<a href="/sources" class="btn-secondary">Back to webhooks</a>
{{else}}
<a href="/pages/login" class="btn-primary">Sign in</a>
{{end}}
</div>
{{end}}
+6
View File
@@ -24,10 +24,14 @@
</svg>
{{.User.Username}}
</a>
{{/* The page for an unknown path is served outside the routes
that issue a form token, and a logout without one is refused. */}}
{{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button>
</form>
{{end}}
{{else}}
<a href="/pages/login" class="btn-primary">Login</a>
{{end}}
@@ -40,10 +44,12 @@
{{if .User}}
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
{{if .CSRFToken}}
<form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button>
</form>
{{end}}
{{else}}
<a href="/pages/login" class="btn-primary w-full">Login</a>
{{end}}