Compare commits
1 Commits
4e6f6ce1d3
...
38c72bcbcc
| Author | SHA1 | Date | |
|---|---|---|---|
| 38c72bcbcc |
18
Dockerfile
18
Dockerfile
@@ -61,28 +61,14 @@ RUN script/fetch-assets
|
||||
|
||||
# Run tests and build
|
||||
RUN make test
|
||||
|
||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||
# nothing in this stage can derive it: script/docker resolves it on the
|
||||
# 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.
|
||||
ARG VERSION=unknown
|
||||
|
||||
RUN make build VERSION="$VERSION"
|
||||
RUN make build
|
||||
|
||||
# Rebuild with static linking for Alpine runtime.
|
||||
# make build already verified compilation.
|
||||
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||
# the binary runs on Alpine without glibc.
|
||||
#
|
||||
# The static flags go in through GO_LDFLAGS rather than a -ldflags of
|
||||
# their own: the build target composes them with the -X that stamps the
|
||||
# version, so this relink cannot silently drop the stamp.
|
||||
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'
|
||||
RUN CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-03-17
|
||||
|
||||
25
Makefile
25
Makefile
@@ -1,26 +1,8 @@
|
||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
# Version stamped into the binary. Derived from git by script/version;
|
||||
# override it (`make build VERSION=v1.2.3`) where git metadata is
|
||||
# unavailable, which is how the Dockerfile passes its build arg in.
|
||||
VERSION ?= $(shell script/version)
|
||||
|
||||
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
||||
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
||||
# exactly as it does in script/version -- stamping "" would leave the binary
|
||||
# reporting no version and the footer back on its "dev" fallback. `override`
|
||||
# is required: a plain assignment loses to the command-line definition it
|
||||
# exists to correct.
|
||||
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
||||
|
||||
# Extra linker flags for the build target. The static relink in the
|
||||
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
|
||||
# so composing flags cannot drop the version stamp.
|
||||
GO_LDFLAGS ?=
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
@@ -46,7 +28,7 @@ check:
|
||||
@script/check
|
||||
|
||||
build:
|
||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||
go build -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
@@ -58,9 +40,6 @@ deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
version:
|
||||
@echo $(VERSION)
|
||||
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
|
||||
373
README.md
373
README.md
@@ -57,8 +57,7 @@ make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||
make test # Run tests with race detection
|
||||
make check # test + lint + fmt-check (CI gate)
|
||||
make build # Build binary to bin/webhooker (version-stamped)
|
||||
make version # Print the version this checkout would stamp
|
||||
make build # Build binary to bin/webhooker
|
||||
make run # build, then run ./bin/webhooker
|
||||
make dev # go run ./cmd/webhooker
|
||||
make deps # go mod download + go mod tidy
|
||||
@@ -75,41 +74,27 @@ you can place variables in a `.env` file in the project root (loaded
|
||||
automatically via `godotenv/autoload`).
|
||||
|
||||
The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev`
|
||||
or `prod` (default: `dev`). The setting controls exactly one behavior:
|
||||
or `prod` (default: `dev`). The setting controls several behaviors:
|
||||
|
||||
| Behavior | `dev` | `prod` |
|
||||
| -------- | ----------------------- | ---------------- |
|
||||
| CORS | Allows any origin (`*`) | Disabled (no-op) |
|
||||
| Behavior | `dev` | `prod` |
|
||||
| --------------------- | -------------------------------- | ------------------------------- |
|
||||
| CORS | Allows any origin (`*`) | Disabled (no-op) |
|
||||
| Session cookie Secure | `false` (works over plain HTTP) | `true` (requires HTTPS) |
|
||||
|
||||
The environment setting does **not** control cookie security. Both the
|
||||
session cookie and the CSRF cookie get their `Secure` flag, and the
|
||||
CSRF middleware its Origin/Referer validation mode, from the transport
|
||||
of each individual request, decided by one predicate —
|
||||
`internal/reqtls.IsTLS`. It reports TLS for a direct TLS connection
|
||||
(`r.TLS`) or for a TLS-terminating reverse proxy that reports one in
|
||||
`X-Forwarded-Proto`:
|
||||
The CSRF cookie's `Secure` flag and Origin/Referer validation mode are
|
||||
determined per-request based on the actual transport protocol, not the
|
||||
environment setting. The middleware checks `r.TLS` (direct TLS) and the
|
||||
`X-Forwarded-Proto` header (TLS-terminating reverse proxy) to decide:
|
||||
|
||||
- **Direct TLS or `X-Forwarded-Proto: https`**: Secure cookies, strict
|
||||
Origin/Referer validation.
|
||||
- **Plaintext HTTP**: Non-Secure cookies, relaxed Origin/Referer
|
||||
checks (token validation still enforced).
|
||||
|
||||
The `X-Forwarded-Proto` value is matched case-insensitively on its
|
||||
first comma-separated element, trimmed, so `HTTPS` and the appended
|
||||
chains a proxy behind another proxy emits (`https, http`) are all read
|
||||
as TLS.
|
||||
|
||||
This means both cookie security and CSRF protection work correctly in
|
||||
all deployment scenarios: behind a TLS-terminating reverse proxy, with
|
||||
direct TLS, or over plain HTTP during development — a plain-HTTP local
|
||||
run gets non-`Secure` cookies and remains usable, and a proxied
|
||||
deployment gets `Secure` ones without the operator setting anything.
|
||||
When running behind a reverse proxy, ensure it sets the
|
||||
`X-Forwarded-Proto: https` header. Unlike `X-Forwarded-For`, this
|
||||
header is read from any peer and is **not** gated by
|
||||
`TRUSTED_PROXIES`; a correctly configured proxy overwrites whatever a
|
||||
client sent. On a listener exposed directly to clients, any client can
|
||||
assert it, so do not run one without a proxy in front.
|
||||
This means CSRF protection works correctly in all deployment scenarios:
|
||||
behind a TLS-terminating reverse proxy, with direct TLS, or over plain
|
||||
HTTP during development. When running behind a reverse proxy, ensure it
|
||||
sets the `X-Forwarded-Proto: https` header.
|
||||
|
||||
All other differences (log format, security headers, etc.) are
|
||||
independent of the environment setting — log format is determined by
|
||||
@@ -281,12 +266,6 @@ The value must be an IP address literal:
|
||||
`net.ipv6.bindv6only=0`) IPv4 as well.
|
||||
- A specific address such as `10.0.0.5` — that interface only.
|
||||
|
||||
An **empty** value is treated as unset, as everywhere else here, and
|
||||
takes the default. In a container that matters: `BIND_ADDRESS=` throws
|
||||
away the image's `0.0.0.0` and falls back to the binary's
|
||||
`127.0.0.1`, which is the one quiet failure this setting has — see
|
||||
[Running with Docker](#running-with-docker).
|
||||
|
||||
Hostnames are **not** accepted. `localhost` aborts startup rather than
|
||||
being resolved: which of `127.0.0.1` and `::1` it means differs by
|
||||
host, a name can resolve to several addresses of which only one could
|
||||
@@ -635,27 +614,14 @@ firewall rules too, since Docker's forwarding rules are inserted ahead
|
||||
of most host firewalls. Publish to the host address your reverse proxy
|
||||
connects from, and nothing wider.
|
||||
|
||||
**An empty `BIND_ADDRESS` is treated as unset**, like every other
|
||||
variable here, so `-e BIND_ADDRESS=` does not mean "keep the image
|
||||
default" — it discards the image's `0.0.0.0` and falls back to the
|
||||
_binary's_ `127.0.0.1`. In a container that is the failure below, and
|
||||
nothing in the logs names the variable. A templated Compose file or a
|
||||
`.env` line with an empty value is the usual way in. Either set a
|
||||
literal or leave the variable out entirely.
|
||||
|
||||
Overriding `BIND_ADDRESS` to a loopback address in a container — by
|
||||
that route or deliberately — makes the container unreachable from
|
||||
outside its namespace even with `-p`. The published port answers
|
||||
nothing, and the health check fails too: it requests
|
||||
`http://localhost:8080`, `localhost` resolves to `::1` first, and a
|
||||
`127.0.0.1` bind is not listening there. The container then goes
|
||||
`unhealthy` about **65 seconds** after start — from `HEALTHCHECK
|
||||
--start-period=5s --interval=30s --retries=3`, so failing probes at
|
||||
5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence
|
||||
during the start period has changed between versions; re-derive from
|
||||
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.
|
||||
If you override `BIND_ADDRESS` to a loopback address in a container,
|
||||
the container is unreachable from outside its namespace even with
|
||||
`-p`: the published port answers nothing, and the health check fails
|
||||
as well — it requests `http://localhost:8080`, `localhost` resolves to
|
||||
`::1` first, and a `127.0.0.1` bind is not listening there, so the
|
||||
container goes `unhealthy` about 95 seconds after start. 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
|
||||
@@ -677,27 +643,17 @@ Five things have to be right. Each one is silent when it is wrong —
|
||||
the service comes up, serves pages, and is broken in a way nothing
|
||||
reports.
|
||||
|
||||
1. **Bind or firewall the app port.** The binary binds `127.0.0.1` by
|
||||
default, so the cleartext listener is not published beside the
|
||||
proxy. The image binds `0.0.0.0` inside its own network namespace
|
||||
and relies on the publish address instead —
|
||||
`-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy
|
||||
and nothing else; widen it only with a firewall or a publish
|
||||
address in front of it. A cleartext port answering the internet
|
||||
serves the admin login form and the unauthenticated receiver with
|
||||
no TLS at all, and the proxy in front of it changes nothing about
|
||||
that.
|
||||
2. **Set `WEBHOOKER_ENVIRONMENT=prod`, and make sure the proxy sends
|
||||
`X-Forwarded-Proto`.** These are two requirements, not one. The
|
||||
environment setting decides CORS and nothing else: the default
|
||||
`dev` answers every origin with `Access-Control-Allow-Origin: *`
|
||||
(without credentials), which a server-rendered production
|
||||
deployment has no use for. Cookie `Secure` and the strict
|
||||
Origin/Referer mode are **not** tied to it — they are decided per
|
||||
request from the transport, which behind a proxy means the
|
||||
`X-Forwarded-Proto` header. The block below sets it; without it
|
||||
every request is read as plaintext and cookies ship without
|
||||
`Secure`. See [Configuration](#configuration).
|
||||
1. **Bind or firewall the app port.** `BIND_ADDRESS` defaults to
|
||||
`127.0.0.1` so the cleartext listener is not published beside the
|
||||
proxy. If you must widen it — a container, or a proxy on another
|
||||
host — firewall the port to the proxy's address. A cleartext port
|
||||
answering the internet serves the admin login form and the
|
||||
unauthenticated receiver with no TLS at all, and the proxy in front
|
||||
of it changes nothing about that.
|
||||
2. **Set `WEBHOOKER_ENVIRONMENT=prod`.** It defaults to `dev`, and the
|
||||
session cookie's `Secure` flag depends on it — see the table in
|
||||
[Configuration](#configuration). Left at the default, a session
|
||||
cookie can be sent over plaintext HTTP.
|
||||
3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
|
||||
limiter keys on the connecting peer, which behind a proxy is the
|
||||
proxy on every request: all clients collapse into one global bucket
|
||||
@@ -779,12 +735,8 @@ peer, so setting them has no effect. See
|
||||
[Trusted proxies](#trusted-proxies) for how the chain is walked.
|
||||
|
||||
`X-Forwarded-Proto: https` is what tells webhooker the request arrived
|
||||
over TLS, which decides the `Secure` flag on both the session and CSRF
|
||||
cookies and the strict Origin/Referer mode. Without it, requests are
|
||||
treated as plaintext and the cookies ship without `Secure`. Unlike
|
||||
`X-Forwarded-For`, this header is read from any peer and is not gated
|
||||
by `TRUSTED_PROXIES`, so the proxy must overwrite whatever a client
|
||||
sent — `$scheme` above does.
|
||||
over TLS, which decides the CSRF cookie's `Secure` flag and the strict
|
||||
Origin/Referer mode. Without it, requests are treated as plaintext.
|
||||
|
||||
With that block, webhooker's environment is:
|
||||
|
||||
@@ -945,9 +897,6 @@ Upgrade procedure:
|
||||
3. Pull the new image and start it.
|
||||
4. Confirm `database migrations completed` in the logs before putting
|
||||
traffic back on it.
|
||||
5. Confirm the new build is the one running:
|
||||
`curl -s http://host:8080/.well-known/healthcheck` reports the
|
||||
version it was stamped with (see [Version stamping](#version-stamping)).
|
||||
|
||||
**Upgrading past the introduction of `BIND_ADDRESS`:** earlier versions
|
||||
always bound every interface. **Container deployments are unaffected**
|
||||
@@ -970,42 +919,6 @@ silent divergence, not a startup error. The only supported way back to
|
||||
an older version is restoring the pre-upgrade backup, which discards
|
||||
everything received since that backup was taken.
|
||||
|
||||
### Version stamping
|
||||
|
||||
The binary reports its version at `/.well-known/healthcheck` (the
|
||||
`version` field), in the UI footer, and in the startup log line
|
||||
(`msg=starting`, `version=...`). It is also the Sentry release name,
|
||||
as `webhooker-{version}`. The value is stamped in at build time by the
|
||||
linker; it is not read from a file at runtime, so it identifies the
|
||||
build itself.
|
||||
|
||||
`script/version` produces the value and both build paths use it:
|
||||
|
||||
| Build | What it reports |
|
||||
| --- | --- |
|
||||
| Clean checkout at a tag | exactly that tag, e.g. `v1.0.0` |
|
||||
| Commits past a tag | `v1.0.0-3-g1a2b3c4` — tag, commits since, short SHA |
|
||||
| No tag reachable | the short SHA, e.g. `1a2b3c4` |
|
||||
| Uncommitted changes | the above with a `-dirty` suffix |
|
||||
| No git metadata | `unknown` |
|
||||
|
||||
`unknown` is what a source tarball or a `docker build .` with no
|
||||
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so
|
||||
the build context carries no git metadata and the image cannot derive
|
||||
the version itself: `script/docker` (and so `make docker`) resolves it
|
||||
on the host and passes it in as the `VERSION` build arg. A build that
|
||||
reports `unknown` is a build nobody told what it was; it is not a
|
||||
failure, but it cannot be traced back to a commit.
|
||||
|
||||
`make version` prints what the current checkout would stamp, and
|
||||
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
||||
`make build VERSION=` or from `--build-arg VERSION=` — means unset
|
||||
rather than `""`, and resolves the way an absent one does.
|
||||
|
||||
Nothing that varies between two builds of the same commit is stamped —
|
||||
no timestamp, no hostname, no builder identity — so two builds of one
|
||||
commit still produce a byte-identical binary.
|
||||
|
||||
### Backups contain secrets
|
||||
|
||||
Treat a backup with the same care as the credentials inside it. Encrypt
|
||||
@@ -1038,29 +951,119 @@ backups at rest and restrict who can read them.
|
||||
credential that was in a backup you cannot account for.
|
||||
- `webhooker.db` stores target config **unencrypted**, tracked at
|
||||
[issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to
|
||||
the session encryption key and the Argon2id password hashes.
|
||||
the session encryption key and the Argon2id password hashes. It also
|
||||
holds each entrypoint's inbound signature secret in the clear, for
|
||||
the reason given under
|
||||
[Inbound Signature Verification](#inbound-signature-verification):
|
||||
HMAC verification needs the key itself, so it cannot be hashed.
|
||||
|
||||
## The entrypoint URL is the authentication secret
|
||||
## Inbound Signature Verification
|
||||
|
||||
The receiver verifies nothing about an inbound request. The UUID in an
|
||||
entrypoint's URL is its credential: anyone who holds that URL can
|
||||
submit events to it, and the receiver checks nothing else about the
|
||||
sender. Treat an entrypoint URL the way you would treat an API token.
|
||||
A receiver URL is a bare v4 UUID in a path. That is unguessable, but it
|
||||
is not a credential: anyone who learns it — from a browser history, a
|
||||
proxy log, a screenshot, a copy-pasted support ticket — can post events
|
||||
that webhooker stores and forwards, and the inbound headers are passed
|
||||
on to your targets almost verbatim, so they also choose what the
|
||||
downstream service sees. Verification is how an entrypoint stops
|
||||
accepting anything that reaches its URL.
|
||||
|
||||
There is no way to rotate the UUID in place. To retire one, delete the
|
||||
entrypoint (or deactivate it, which answers `410`) and create a new
|
||||
one, then point the sender at the new URL.
|
||||
It is optional and configured per entrypoint. An entrypoint with no
|
||||
scheme selected is not verified, which is what every entrypoint was
|
||||
before this existed and what every entrypoint remains after an
|
||||
upgrade — enabling verification is always a deliberate act, and no
|
||||
existing deployment is locked out of its own receivers by installing a
|
||||
new version.
|
||||
|
||||
When a scheme **is** selected, a request whose signature is missing,
|
||||
malformed or wrong is answered `401` and **nothing is stored**: no
|
||||
event row, no delivery row, no delivery attempt. Rejection happens
|
||||
after the body is read (the signature covers it) and before the first
|
||||
write.
|
||||
|
||||
### Supported schemes
|
||||
|
||||
| Scheme | Header | Check |
|
||||
| -------- | --------------------- | ----- |
|
||||
| `github` | `X-Hub-Signature-256` | HMAC-SHA256 of the raw request body under the shared secret, hex-encoded, prefixed `sha256=` |
|
||||
| `gitlab` | `X-Gitlab-Token` | The header is the shared secret itself, compared as-is |
|
||||
|
||||
Both comparisons run in constant time (`hmac.Equal`). The HMAC is
|
||||
computed over the request body exactly as received, before any parsing,
|
||||
and under the same 1 MB body cap every other request obeys — an
|
||||
unsigned sender cannot make webhooker buffer more than a signed one.
|
||||
|
||||
GitHub's older SHA-1 `X-Hub-Signature` is **not** accepted. Neither is
|
||||
a GitHub digest sent without its `sha256=` prefix.
|
||||
|
||||
### Configuring a sender
|
||||
|
||||
The secret is a value you choose and enter in two places: at the sender
|
||||
and in webhooker. webhooker never generates or displays one, so there
|
||||
is no stored credential the UI can be made to reveal.
|
||||
|
||||
1. Generate a secret, e.g. `openssl rand -hex 32`.
|
||||
2. In webhooker, open the webhook's page, find the entrypoint, and
|
||||
click **Configure** (or **Rotate**, if it already has one). Select
|
||||
the scheme and paste the secret. Surrounding whitespace is stripped,
|
||||
so a value pasted with a trailing space still works; a secret whose
|
||||
own first or last character is a space cannot be stored.
|
||||
3. At the sender:
|
||||
- **GitHub** — repository (or organization) → Settings → Webhooks →
|
||||
the hook → **Secret**. GitHub then signs every delivery with
|
||||
`X-Hub-Signature-256`.
|
||||
- **GitLab** — project → Settings → Webhooks → the hook → **Secret
|
||||
token**. GitLab sends it verbatim as `X-Gitlab-Token`.
|
||||
|
||||
**Rotation** is the same form: submit the new secret. Deliveries signed
|
||||
with the old secret are rejected from that moment, so change it at the
|
||||
sender in the same sitting. Selecting **None** removes verification and
|
||||
deletes the stored secret with it.
|
||||
|
||||
The page shows which scheme an entrypoint uses and which header it
|
||||
reads, never the secret. The value is stored in the clear — HMAC
|
||||
verification needs the key itself, and a hash of it cannot recompute a
|
||||
sender's digest — so it is handled like the other credentials
|
||||
webhooker holds: excluded from JSON, kept out of templates by a
|
||||
projection (`handlers.EntrypointView`), and absent from every log line,
|
||||
including the ones written when verification fails.
|
||||
|
||||
### The credential is not stored or forwarded
|
||||
|
||||
Under the `gitlab` scheme the signature header **is** the secret. An
|
||||
accepted request's headers are persisted on the event and forwarded to
|
||||
every delivery target, so `X-Gitlab-Token` is removed from that copy
|
||||
before the event is written — otherwise every target operator, every
|
||||
backup and everyone with read access to `events-*.db` would hold the
|
||||
value needed to forge signed requests to the entrypoint it protects.
|
||||
The sender's other headers are untouched, and the request the receiver
|
||||
itself verifies against is not modified.
|
||||
|
||||
The stripping is driven by the scheme's own description rather than by
|
||||
a header name, and a scheme is stripped unless it declares that its
|
||||
header carries a digest. `github` declares it: `X-Hub-Signature-256` is
|
||||
an HMAC over the body, from which the key cannot be recovered, so it is
|
||||
stored and forwarded intact. A scheme added later is stripped by
|
||||
default.
|
||||
|
||||
### When configuration is broken
|
||||
|
||||
An entrypoint whose stored scheme this build does not recognise, or
|
||||
which has one half of the scheme/secret pair and not the other, is
|
||||
answered `500` and stores nothing. It is not treated as unverified. The
|
||||
UI cannot create such a row — it rejects an unknown scheme with a `400`
|
||||
— so this covers a hand-edited database or a downgrade to a build that
|
||||
predates a scheme. Failing closed is the point: an entrypoint the
|
||||
operator believes is protected must never quietly go back to accepting
|
||||
anything.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
This repository adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
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`. We
|
||||
provide:
|
||||
development workflow. Ten of the Makefile's sixteen targets are thin
|
||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean` and `css`
|
||||
are inline commands with no script behind them. We provide:
|
||||
|
||||
- `script/bootstrap` — install all dependencies (idempotent)
|
||||
- `script/setup` — make a fresh clone ready for development
|
||||
@@ -1073,11 +1076,7 @@ provide:
|
||||
- `script/fmt` — format all code (writes)
|
||||
- `script/fmt-check` — check formatting (read-only)
|
||||
- `script/check` — run test, lint, and fmt-check
|
||||
- `script/version` — output the version to stamp into the binary (see
|
||||
[Version stamping](#version-stamping))
|
||||
- `script/docker` — build the Docker image tagged via
|
||||
`script/projectname`, passing `script/version`'s output in as the
|
||||
`VERSION` build arg
|
||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
||||
runs the checks, so a green build implies a green repo)
|
||||
- `script/ci-mark-superseded` — CI helper: mark the commits whose run a
|
||||
@@ -1335,12 +1334,18 @@ the full request and creates an Event.
|
||||
| -------------- | ------- | ----------- |
|
||||
| `id` | UUID | Primary key |
|
||||
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||
| `path` | string | Unique bare UUID, generated at creation. The `/webhook/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
|
||||
| `path` | string | Unique bare UUID, generated at creation. The `/webhook/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment |
|
||||
| `description` | string | Optional description |
|
||||
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
||||
| `signature_scheme` | string | How inbound requests are authenticated: `github`, `gitlab`, or empty for no verification (default: empty). See [Inbound Signature Verification](#inbound-signature-verification) |
|
||||
| `signature_secret` | string | The secret shared with the sender, stored in the clear because HMAC verification needs the key itself. Never marshalled to JSON, never rendered, never logged. Empty when no scheme is set |
|
||||
|
||||
**Relations:** Belongs to Webhook.
|
||||
|
||||
Both signature columns arrive through `AutoMigrate` with an empty
|
||||
default, so every entrypoint written before they existed migrates to
|
||||
"not configured" and keeps accepting the traffic it already accepted.
|
||||
|
||||
A webhook can have multiple entrypoints. This allows separate URLs for
|
||||
different event sources that all feed into the same processing pipeline
|
||||
(e.g., one entrypoint for GitHub, another for Stripe, both routing to
|
||||
@@ -1534,6 +1539,10 @@ fired twenty times at a backend stays traceable. Resubmitting the same
|
||||
event repeatedly is supported and is the point of the action — there is
|
||||
no in-flight refusal; the route's own rate limit is what bounds it.
|
||||
|
||||
Inbound signature verification is not re-run on a resubmit. There is no
|
||||
inbound signature to check on a copy the operator submits, and the
|
||||
route is authenticated and CSRF-protected as an operator action.
|
||||
|
||||
#### DeliveryResult
|
||||
|
||||
The result of a single delivery attempt. Every attempt (including
|
||||
@@ -1694,15 +1703,17 @@ External Service
|
||||
│ │ │ Stack │ │ Handler │
|
||||
└─────────────┘ └──────────────┘ └──────┬───────┘
|
||||
│
|
||||
1. Look up Entrypoint by UUID — 404 if unknown,
|
||||
410 if inactive
|
||||
1. Look up Entrypoint by UUID
|
||||
2. Read the body under the 1 MB cap
|
||||
3. Capture full request as Event
|
||||
4. Create Delivery records for each active Target
|
||||
5. Build self-contained delivery.Task structs
|
||||
3. Verify the signature, if the entrypoint has
|
||||
one configured — 401 and no writes if it
|
||||
fails (see Inbound Signature Verification)
|
||||
4. Capture full request as Event
|
||||
5. Create Delivery records for each active Target
|
||||
6. Build self-contained delivery.Task structs
|
||||
(target config + event data inline for
|
||||
bodies < 16 KiB)
|
||||
6. Notify Engine via channel (no DB read needed)
|
||||
7. Notify Engine via channel (no DB read needed)
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
@@ -2074,16 +2085,13 @@ the rest. Nothing dropped is needed for the likeliest use, debugging a
|
||||
CSRF rejection. Its three inputs are the TLS decision, `Origin` and
|
||||
`Referer`; the latter two are kept, and the first is the scheme of the
|
||||
retained URL, because the SDK derives that scheme from
|
||||
`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That
|
||||
predicate is the SDK's own and is stricter than `internal/reqtls.IsTLS`,
|
||||
which this service now uses everywhere it decides transport: the SDK
|
||||
reports `http` for the `HTTPS` and `https, http` spellings `reqtls`
|
||||
accepts. Only a reported scheme is affected, no decision is, so it is
|
||||
left to the SDK rather than reimplemented. That is what the rewrite
|
||||
above preserves it for, and it is why dropping `X-Forwarded-Proto`
|
||||
costs nothing. The dropped provider headers (`X-GitHub-Event`,
|
||||
`X-Gitlab-Event` and the like) are real signal but are recorded
|
||||
locally on the event, and
|
||||
`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"` — byte
|
||||
for byte the predicate `internal/middleware/csrf.go` uses to choose
|
||||
between the `csrf.Secure(true)` and `csrf.Secure(false)` handlers.
|
||||
That is what the rewrite above preserves it for, and it is why
|
||||
dropping `X-Forwarded-Proto` costs nothing. The dropped provider
|
||||
headers (`X-GitHub-Event`, `X-Gitlab-Event` and the like) are real
|
||||
signal but are recorded locally on the event, and
|
||||
`Sentry-Trace`/`Baggage` are already reflected in the event's trace
|
||||
context.
|
||||
|
||||
@@ -2507,6 +2515,7 @@ abuse limit later; they are tracked as future work.
|
||||
| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
|
||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/secret` | Set, rotate or remove the entrypoint's inbound signature scheme and secret (see [Inbound Signature Verification](#inbound-signature-verification)) |
|
||||
| `POST` | `/source/{id}/targets` | Add target to webhook |
|
||||
| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target |
|
||||
| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||
@@ -2557,7 +2566,7 @@ webhooker/
|
||||
│ │ ├── model_setting.go # Setting entity (key-value app config)
|
||||
│ │ ├── model_user.go # User entity
|
||||
│ │ ├── model_webhook.go # Webhook entity
|
||||
│ │ ├── model_entrypoint.go # Entrypoint entity
|
||||
│ │ ├── model_entrypoint.go # Entrypoint entity and SignatureScheme enum
|
||||
│ │ ├── model_target.go # Target entity and TargetType enum
|
||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||
@@ -2619,9 +2628,11 @@ webhooker/
|
||||
│ │ ├── server.go # Server struct, fx lifecycle, signal handling
|
||||
│ │ ├── http.go # HTTP server setup with timeouts
|
||||
│ │ └── routes.go # All route definitions
|
||||
│ └── session/
|
||||
│ ├── session.go # Cookie-based session management
|
||||
│ └── testing.go # NewForTest: Session without the fx lifecycle
|
||||
│ ├── session/
|
||||
│ │ ├── session.go # Cookie-based session management
|
||||
│ │ └── testing.go # NewForTest: Session without the fx lifecycle
|
||||
│ └── signature/
|
||||
│ └── signature.go # Inbound signature verification (GitHub, GitLab)
|
||||
├── static/
|
||||
│ ├── static.go # //go:embed directive
|
||||
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
||||
@@ -2634,7 +2645,7 @@ webhooker/
|
||||
├── script/ # Scripts to Rule Them All entrypoints
|
||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||
├── Makefile # 10 of 17 targets shim script/; 7 are inline
|
||||
├── Makefile # 10 of 16 targets shim script/; 6 are inline
|
||||
├── go.mod / go.sum
|
||||
└── .golangci.yml # Linter configuration
|
||||
```
|
||||
@@ -2736,9 +2747,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
|
||||
- **Web UI:** Cookie-based sessions using gorilla/sessions with
|
||||
encrypted cookies. Sessions are configured with HttpOnly, SameSite
|
||||
Lax, and Secure whenever the request is on TLS — the flag follows the
|
||||
request's transport, not the environment. Absolute session lifetime
|
||||
is 7 days, with a sliding idle timeout on top of it (see
|
||||
Lax, and Secure (in production). Absolute session lifetime is 7 days,
|
||||
with a sliding idle timeout on top of it (see
|
||||
[Sessions](#sessions)).
|
||||
- **API (planned):** API key authentication via `Authorization: Bearer`
|
||||
header. API keys are stored per-user with usage tracking
|
||||
@@ -2751,10 +2761,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
### Security
|
||||
|
||||
- Passwords hashed with Argon2id (64 MB memory cost)
|
||||
- Session cookies are HttpOnly, SameSite Lax, and Secure on any request
|
||||
that arrived over TLS (directly or through a reverse proxy reporting
|
||||
it), decided per-request by `internal/reqtls.IsTLS` rather than by the
|
||||
configured environment
|
||||
- Session cookies are HttpOnly, SameSite Lax, Secure (prod only)
|
||||
- Session regeneration on login to prevent session fixation attacks
|
||||
- Session key is a 32-byte value auto-generated on first startup and
|
||||
stored in the database
|
||||
@@ -2767,14 +2774,18 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
on all state-changing forms (cookie-based double-submit tokens with
|
||||
HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
|
||||
`/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and
|
||||
`/api` (stateless API). The middleware detects TLS per-request through
|
||||
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||
to set appropriate cookie security flags and Origin/Referer validation
|
||||
mode
|
||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||
about an inbound request is verified; possession of the UUID
|
||||
authorises submission (see
|
||||
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret))
|
||||
`/api` (stateless API). The middleware auto-detects TLS status
|
||||
per-request (via `r.TLS` and `X-Forwarded-Proto`) to set appropriate
|
||||
cookie security flags and Origin/Referer validation mode
|
||||
- **Optional inbound signature verification** per entrypoint (GitHub
|
||||
`X-Hub-Signature-256`, GitLab `X-Gitlab-Token`). Off by default and
|
||||
off after an upgrade, so behaviour is unchanged until an operator
|
||||
turns it on. Where it is on, an unsigned or wrongly signed request
|
||||
is `401` and is not persisted, and a configuration the receiver
|
||||
cannot apply fails closed rather than reverting to unverified. The
|
||||
comparison is constant time and the secret never reaches a template,
|
||||
a JSON response or a log line (see
|
||||
[Inbound Signature Verification](#inbound-signature-verification))
|
||||
- **SSRF prevention** for HTTP delivery targets: private/reserved IP
|
||||
ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked
|
||||
both at target creation time (URL validation) and at delivery time
|
||||
@@ -2934,11 +2945,7 @@ version is fixed independently of the compiler's:
|
||||
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)).
|
||||
`CGO_ENABLED=1` and static linking so it runs on musl.
|
||||
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,
|
||||
|
||||
@@ -44,21 +44,14 @@ const (
|
||||
// the host is then a deliberate act — a reverse proxy in front
|
||||
// of it, or an explicit BIND_ADDRESS.
|
||||
//
|
||||
// This is the binary's default only. The Dockerfile ships
|
||||
// ENV BIND_ADDRESS=0.0.0.0, so a container deployment needs
|
||||
// nothing set and is unaffected by this constant. The two
|
||||
// differ because they answer different questions: a container's
|
||||
// network namespace is already the boundary this default is
|
||||
// reaching for, so binding every address inside it exposes
|
||||
// nothing, and what decides exposure there is the publish flag
|
||||
// (-p 127.0.0.1:8080:8080). A loopback bind inside a container
|
||||
// buys no security and makes the process unreachable through
|
||||
// its own published port.
|
||||
//
|
||||
// The split is expressed as two explicit defaults rather than
|
||||
// container auto-detection, because a heuristic that guesses
|
||||
// wrong opens the cleartext port exactly where nobody is
|
||||
// looking.
|
||||
// A container needs BIND_ADDRESS=0.0.0.0 set explicitly: a
|
||||
// loopback-bound process is unreachable from outside its
|
||||
// network namespace even with -p. That is deliberate. The
|
||||
// container fails its healthcheck immediately and visibly,
|
||||
// where the wildcard default fails silently in the direction of
|
||||
// exposure. There is no container auto-detection here, because
|
||||
// a heuristic that guesses wrong opens the cleartext port
|
||||
// exactly where nobody is looking.
|
||||
defaultBindAddress = "127.0.0.1"
|
||||
|
||||
// defaultRetentionSweepInterval is how often the retention
|
||||
|
||||
85
internal/database/migration_entrypoint_test.go
Normal file
85
internal/database/migration_entrypoint_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
)
|
||||
|
||||
// TestEntrypointSignatureColumnsMigrateToUnconfigured pins the
|
||||
// upgrade path for a deployment that already has entrypoints.
|
||||
//
|
||||
// The signature columns arrive through GORM's AutoMigrate, so every
|
||||
// row written before they existed acquires them with no value. That
|
||||
// has to land on "not configured", because the alternative is an
|
||||
// upgrade that rejects the traffic the operator was already
|
||||
// receiving — a self-inflicted outage on a receiver whose senders
|
||||
// cannot be told to start signing.
|
||||
//
|
||||
// The legacy schema is reproduced by dropping the columns from a
|
||||
// migrated database and writing a row through the old shape, so the
|
||||
// row really predates them rather than merely being blank.
|
||||
func TestEntrypointSignatureColumnsMigrateToUnconfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, lc := setupTestDB(t)
|
||||
lc.RequireStart()
|
||||
|
||||
t.Cleanup(lc.RequireStop)
|
||||
|
||||
for _, column := range []string{
|
||||
"signature_scheme", "signature_secret",
|
||||
} {
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Exec(
|
||||
"ALTER TABLE entrypoints DROP COLUMN "+column,
|
||||
).Error,
|
||||
"dropping %s to reproduce the pre-upgrade schema",
|
||||
column,
|
||||
)
|
||||
}
|
||||
|
||||
const legacyID = "legacy-entrypoint"
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Exec(
|
||||
`INSERT INTO entrypoints
|
||||
(id, created_at, updated_at, webhook_id, path,
|
||||
description, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
legacyID, "2026-01-01 00:00:00", "2026-01-01 00:00:00",
|
||||
"legacy-webhook", "legacy-path", "predates signatures",
|
||||
true,
|
||||
).Error,
|
||||
)
|
||||
|
||||
// The upgrade.
|
||||
require.NoError(t, db.Migrate())
|
||||
|
||||
var ep database.Entrypoint
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Where("id = ?", legacyID).First(&ep).Error,
|
||||
"the migrated row must still load; a NULL landing in a "+
|
||||
"string column would fail here",
|
||||
)
|
||||
|
||||
assert.Equal(t, database.SignatureSchemeNone, ep.SignatureScheme)
|
||||
assert.Empty(t, ep.SignatureSecret)
|
||||
assert.False(t, ep.SignatureConfigured())
|
||||
assert.True(t, ep.Active, "the row's other columns survive")
|
||||
|
||||
// The behaviour that actually matters: an unsigned request to
|
||||
// this entrypoint is still accepted.
|
||||
assert.NoError(
|
||||
t,
|
||||
signature.Verify(&ep, http.Header{}, []byte(`{"a":1}`)),
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,71 @@
|
||||
package database
|
||||
|
||||
// SignatureScheme names the way an entrypoint authenticates inbound
|
||||
// requests. A scheme fixes both the header the signature arrives in
|
||||
// and the algorithm used to check it, so an operator cannot pair one
|
||||
// sender's header with another sender's comparison.
|
||||
type SignatureScheme string
|
||||
|
||||
// Signature scheme values. The empty scheme means the entrypoint
|
||||
// performs no inbound verification: it is the default, and it is the
|
||||
// state every entrypoint created before this column existed migrates
|
||||
// to, so an existing deployment keeps accepting the requests it
|
||||
// accepted before.
|
||||
const (
|
||||
SignatureSchemeNone SignatureScheme = ""
|
||||
SignatureSchemeGitHub SignatureScheme = "github"
|
||||
SignatureSchemeGitLab SignatureScheme = "gitlab"
|
||||
)
|
||||
|
||||
// Entrypoint represents an inbound URL endpoint that feeds into a webhook
|
||||
type Entrypoint struct {
|
||||
BaseModel
|
||||
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||
|
||||
// Path is the URL path for this entrypoint. It is the
|
||||
// entrypoint's only credential: possession of the UUID
|
||||
// authorises submission, so the receiver checks nothing else
|
||||
// about the sender.
|
||||
// Path is the URL path for this entrypoint.
|
||||
Path string `gorm:"uniqueIndex;not null" json:"path"`
|
||||
|
||||
Description string `json:"description"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
|
||||
// SignatureScheme selects how inbound requests to this
|
||||
// entrypoint are authenticated. Empty means unauthenticated,
|
||||
// which is what a UUID-only entrypoint has always been.
|
||||
SignatureScheme SignatureScheme `gorm:"default:''" json:"signatureScheme"`
|
||||
|
||||
// SignatureSecret is the secret shared with the sender.
|
||||
//
|
||||
// It is stored in the clear because HMAC verification needs the
|
||||
// key itself: a hash of it cannot recompute the sender's digest.
|
||||
// It is therefore a live credential, and json:"-" keeps it out of
|
||||
// any handler that marshals the model, the way APIKey.Key and
|
||||
// Target.Config are kept out. handlers.EntrypointView is the
|
||||
// matching barrier for the HTML path.
|
||||
SignatureSecret string `gorm:"default:''" json:"-"`
|
||||
|
||||
// Relations
|
||||
Webhook Webhook `json:"webhook,omitzero"`
|
||||
}
|
||||
|
||||
// SignatureConfigured reports whether this entrypoint verifies
|
||||
// inbound requests. Both halves must be present: a scheme without a
|
||||
// secret, or a secret without a scheme, is a broken configuration
|
||||
// rather than a configured one, and signature.Verify fails those
|
||||
// closed rather than treating them as "off".
|
||||
func (e *Entrypoint) SignatureConfigured() bool {
|
||||
return e.SignatureScheme != SignatureSchemeNone &&
|
||||
e.SignatureSecret != ""
|
||||
}
|
||||
|
||||
// SignatureHalfConfigured reports whether exactly one half of the
|
||||
// scheme/secret pair is present. The receiver refuses such a row on
|
||||
// every request, so the UI must not describe it as unverified. It
|
||||
// reports the state without exposing the secret, which is why it
|
||||
// lives here rather than in the display projection.
|
||||
func (e *Entrypoint) SignatureHalfConfigured() bool {
|
||||
hasScheme := e.SignatureScheme != SignatureSchemeNone
|
||||
hasSecret := e.SignatureSecret != ""
|
||||
|
||||
return hasScheme != hasSecret
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ func marshalModel(t *testing.T, v any) string {
|
||||
// - APIKey.Key is a bearer token outright.
|
||||
// - Setting.Value holds the session encryption key.
|
||||
// - User.Password holds the Argon2 hash, and was already tagged.
|
||||
// - Entrypoint.SignatureSecret is the secret its senders sign with,
|
||||
// stored in the clear because HMAC verification needs the key.
|
||||
func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -72,6 +74,14 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
||||
Password: marker,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "entrypoint signature secret",
|
||||
model: database.Entrypoint{
|
||||
Description: keptField,
|
||||
SignatureScheme: database.SignatureSchemeGitHub,
|
||||
SignatureSecret: marker,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -105,3 +115,24 @@ func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
|
||||
assert.NotContains(t, encoded, marker)
|
||||
assert.Contains(t, encoded, keptField)
|
||||
}
|
||||
|
||||
// TestWebhookMarshalsNoEntrypointSecret covers the same nested case
|
||||
// for the entrypoint's inbound signature secret, which reaches a
|
||||
// marshalled webhook through the Entrypoints association.
|
||||
func TestWebhookMarshalsNoEntrypointSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const marker = "QQENTRYPOINTMARKERQQ"
|
||||
|
||||
encoded := marshalModel(t, database.Webhook{
|
||||
Name: keptField,
|
||||
Entrypoints: []database.Entrypoint{{
|
||||
Path: "some-uuid",
|
||||
SignatureScheme: database.SignatureSchemeGitLab,
|
||||
SignatureSecret: marker,
|
||||
}},
|
||||
})
|
||||
|
||||
assert.NotContains(t, encoded, marker)
|
||||
assert.Contains(t, encoded, keptField)
|
||||
}
|
||||
|
||||
142
internal/delivery/target_http_secret_test.go
Normal file
142
internal/delivery/target_http_secret_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
)
|
||||
|
||||
// gitlabDeliverySecret is the shared secret the entrypoint in these
|
||||
// tests is configured with. No outbound request may contain it.
|
||||
const gitlabDeliverySecret = "QQDELIVERYSECRETQQ"
|
||||
|
||||
// receivedEventHeaders builds the Event.Headers value the receiver
|
||||
// stores for an inbound request, by running the request's headers
|
||||
// through the same sanitizer the receive path uses. Going through
|
||||
// signature.SanitizeHeaders rather than a literal is the point of
|
||||
// the test: it joins the two egresses at the field they share, so a
|
||||
// regression at either end shows up here.
|
||||
func receivedEventHeaders(
|
||||
t *testing.T,
|
||||
scheme database.SignatureScheme,
|
||||
inbound http.Header,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
ep := &database.Entrypoint{
|
||||
SignatureScheme: scheme,
|
||||
SignatureSecret: gitlabDeliverySecret,
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(
|
||||
signature.SanitizeHeaders(ep, inbound),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// TestApplyRequestHeadersDropsInboundCredential proves a delivery to
|
||||
// an HTTP target does not carry the GitLab shared secret.
|
||||
//
|
||||
// isForwardableHeader is a blocklist of hop-by-hop names, so it
|
||||
// forwards X-Gitlab-Token like any other header; what keeps the
|
||||
// secret out of the outbound request is that the receiver never
|
||||
// stored it. Handing a target operator the token would hand them the
|
||||
// ability to forge requests to the entrypoint it authenticates,
|
||||
// which is the one control the receiver has.
|
||||
func TestApplyRequestHeadersDropsInboundCredential(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inbound := http.Header{}
|
||||
inbound.Set(signature.HeaderGitLab, gitlabDeliverySecret)
|
||||
inbound.Set("X-Gitlab-Event", "Push Hook")
|
||||
|
||||
event := &database.Event{
|
||||
Headers: receivedEventHeaders(
|
||||
t, database.SignatureSchemeGitLab, inbound,
|
||||
),
|
||||
ContentType: "application/json",
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"https://target.example.com/hook",
|
||||
http.NoBody,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
delivery.ExportApplyRequestHeaders(
|
||||
req, event, &delivery.HTTPTargetConfig{},
|
||||
)
|
||||
|
||||
assert.Empty(
|
||||
t,
|
||||
req.Header.Values(signature.HeaderGitLab),
|
||||
"the shared secret header must not reach a target",
|
||||
)
|
||||
|
||||
// Header.Values canonicalises, so a differently-cased spelling
|
||||
// would be caught above; this catches the value arriving under
|
||||
// some other name.
|
||||
for name, values := range req.Header {
|
||||
for _, v := range values {
|
||||
assert.NotContains(
|
||||
t, v, gitlabDeliverySecret,
|
||||
"secret present in outbound header %s", name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The rest of the sender's headers still arrive. A fix that
|
||||
// dropped everything would pass the assertions above while
|
||||
// breaking delivery.
|
||||
assert.Equal(
|
||||
t,
|
||||
"Push Hook",
|
||||
req.Header.Get("X-Gitlab-Event"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestApplyRequestHeadersKeepsGitHubDigest proves the stripping is
|
||||
// scoped to headers that carry the secret itself. GitHub's
|
||||
// X-Hub-Signature-256 is an HMAC over the body, so a target can be
|
||||
// shown it without being handed the key.
|
||||
func TestApplyRequestHeadersKeepsGitHubDigest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const digest = "sha256=deadbeef"
|
||||
|
||||
inbound := http.Header{}
|
||||
inbound.Set(signature.HeaderGitHub, digest)
|
||||
|
||||
event := &database.Event{
|
||||
Headers: receivedEventHeaders(
|
||||
t, database.SignatureSchemeGitHub, inbound,
|
||||
),
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"https://target.example.com/hook",
|
||||
http.NoBody,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
delivery.ExportApplyRequestHeaders(
|
||||
req, event, &delivery.HTTPTargetConfig{},
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, digest, req.Header.Get(signature.HeaderGitHub),
|
||||
)
|
||||
}
|
||||
342
internal/handlers/entrypoint_secret_test.go
Normal file
342
internal/handlers/entrypoint_secret_test.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// submitEntrypointSecret posts the signature configuration form for
|
||||
// an entrypoint and returns the recorder.
|
||||
func submitEntrypointSecret(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
cookies []*http.Cookie,
|
||||
webhookID, entrypointID, scheme, secret string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("signature_scheme", scheme)
|
||||
form.Set("secret", secret)
|
||||
|
||||
req := formRequest(
|
||||
"/source/"+webhookID+"/entrypoints/"+
|
||||
entrypointID+"/secret",
|
||||
cookies,
|
||||
form,
|
||||
map[string]string{
|
||||
paramSourceID: webhookID,
|
||||
entrypointIDParam: entrypointID,
|
||||
},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.HandleEntrypointSecret().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// reloadEntrypoint reads an entrypoint back from the database,
|
||||
// including the columns the model keeps out of JSON.
|
||||
func reloadEntrypoint(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
id string,
|
||||
) database.Entrypoint {
|
||||
t.Helper()
|
||||
|
||||
var ep database.Entrypoint
|
||||
|
||||
require.NoError(
|
||||
t, db.DB().Where("id = ?", id).First(&ep).Error,
|
||||
)
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
// TestEntrypointSecretSetRotateAndRemove walks the whole lifecycle
|
||||
// the UI has to support: turning verification on, rotating the secret
|
||||
// to a new value, and turning it back off.
|
||||
func TestEntrypointSecretSetRotateAndRemove(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
cookies := authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
)
|
||||
wh := seedWebhook(t, db)
|
||||
ep := seedSignedEntrypoint(
|
||||
t, db, wh.ID, database.SignatureSchemeNone, "",
|
||||
)
|
||||
|
||||
// Set.
|
||||
w := submitEntrypointSecret(
|
||||
t, h, cookies, wh.ID, ep.ID, "github", inboundSecret,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
stored := reloadEntrypoint(t, db, ep.ID)
|
||||
assert.Equal(
|
||||
t, database.SignatureSchemeGitHub, stored.SignatureScheme,
|
||||
)
|
||||
assert.Equal(t, inboundSecret, stored.SignatureSecret)
|
||||
assert.True(t, stored.SignatureConfigured())
|
||||
|
||||
// Rotate: a new secret and a different scheme in one submission.
|
||||
// The new value is submitted with surrounding whitespace, the way
|
||||
// a secret pasted out of a password manager arrives; storing that
|
||||
// verbatim would make every later request fail verification with
|
||||
// nothing visible on either side to explain it.
|
||||
const rotated = "QQROTATEDSECRETQQ"
|
||||
|
||||
w = submitEntrypointSecret(
|
||||
t, h, cookies, wh.ID, ep.ID, "gitlab", " "+rotated+"\t",
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
stored = reloadEntrypoint(t, db, ep.ID)
|
||||
assert.Equal(
|
||||
t, database.SignatureSchemeGitLab, stored.SignatureScheme,
|
||||
)
|
||||
assert.Equal(t, rotated, stored.SignatureSecret)
|
||||
|
||||
// Remove. The secret has to go with the scheme: a stored
|
||||
// credential nothing reads is one more copy to leak.
|
||||
w = submitEntrypointSecret(t, h, cookies, wh.ID, ep.ID, "", "")
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
stored = reloadEntrypoint(t, db, ep.ID)
|
||||
assert.Equal(
|
||||
t, database.SignatureSchemeNone, stored.SignatureScheme,
|
||||
)
|
||||
assert.Empty(t, stored.SignatureSecret)
|
||||
assert.False(t, stored.SignatureConfigured())
|
||||
}
|
||||
|
||||
// TestEntrypointSecretRejectsBadInput proves the form cannot create a
|
||||
// row the receiver would later have to refuse. Both rejections leave
|
||||
// the stored configuration untouched rather than half-applied.
|
||||
func TestEntrypointSecretRejectsBadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
scheme string
|
||||
secret string
|
||||
}{
|
||||
{
|
||||
name: "unsupported scheme",
|
||||
scheme: "stripe",
|
||||
secret: inboundSecret,
|
||||
},
|
||||
{
|
||||
name: "scheme with no secret",
|
||||
scheme: "github",
|
||||
secret: "",
|
||||
},
|
||||
{
|
||||
// Whitespace is stripped, so a secret of spaces is an
|
||||
// empty one.
|
||||
name: "scheme with blank secret",
|
||||
scheme: "github",
|
||||
secret: " ",
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
cookies := authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
)
|
||||
|
||||
for _, tc := range cases {
|
||||
wh := seedWebhook(t, db)
|
||||
ep := seedSignedEntrypoint(
|
||||
t, db, wh.ID,
|
||||
database.SignatureSchemeGitLab, inboundSecret,
|
||||
)
|
||||
|
||||
w := submitEntrypointSecret(
|
||||
t, h, cookies, wh.ID, ep.ID, tc.scheme, tc.secret,
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusBadRequest, w.Code, "case %s", tc.name,
|
||||
)
|
||||
|
||||
stored := reloadEntrypoint(t, db, ep.ID)
|
||||
assert.Equal(
|
||||
t,
|
||||
database.SignatureSchemeGitLab,
|
||||
stored.SignatureScheme,
|
||||
"case %s", tc.name,
|
||||
)
|
||||
assert.Equal(
|
||||
t, inboundSecret, stored.SignatureSecret,
|
||||
"case %s", tc.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntrypointSecretRequiresOwnership proves the configuration
|
||||
// endpoint is bound by the same ownership check as the rest of the
|
||||
// webhook's pages: another user's entrypoint is a 404, and the secret
|
||||
// is not touched.
|
||||
func TestEntrypointSecretRequiresOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
ep := seedSignedEntrypoint(
|
||||
t, db, wh.ID,
|
||||
database.SignatureSchemeGitLab, inboundSecret,
|
||||
)
|
||||
|
||||
stranger := authenticatedCookies(
|
||||
t, sess, "someone-else", "someoneelse",
|
||||
)
|
||||
|
||||
w := submitEntrypointSecret(
|
||||
t, h, stranger, wh.ID, ep.ID, "github", "hijacked",
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
inboundSecret,
|
||||
reloadEntrypoint(t, db, ep.ID).SignatureSecret,
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_MasksEntrypointSecret is the regression test
|
||||
// for the credential on the entrypoint: the page has to say that
|
||||
// verification is configured and which header carries it, without the
|
||||
// secret itself ever reaching the rendered HTML.
|
||||
func TestHandleSourceDetail_MasksEntrypointSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
seedSignedEntrypoint(
|
||||
t, db, wh.ID,
|
||||
database.SignatureSchemeGitHub, inboundSecret,
|
||||
)
|
||||
|
||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||
|
||||
assert.NotContains(t, body, inboundSecret)
|
||||
assert.Contains(t, body, "GitHub")
|
||||
assert.Contains(t, body, "X-Hub-Signature-256")
|
||||
}
|
||||
|
||||
// TestEntrypointViewsDropTheSecret pins the projection itself, so the
|
||||
// barrier survives a template rewrite that stops rendering the field
|
||||
// the page test above looks at.
|
||||
func TestEntrypointViewsDropTheSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
views := handlers.NewEntrypointViews([]database.Entrypoint{
|
||||
{
|
||||
Path: "p1",
|
||||
Active: true,
|
||||
SignatureScheme: database.SignatureSchemeGitHub,
|
||||
SignatureSecret: inboundSecret,
|
||||
},
|
||||
{
|
||||
Path: "p2",
|
||||
},
|
||||
{
|
||||
// Half a configuration. The receiver 500s every request
|
||||
// to this row, so the UI must not call it unverified.
|
||||
Path: "p2a",
|
||||
SignatureScheme: database.SignatureSchemeGitLab,
|
||||
},
|
||||
{
|
||||
// The other half.
|
||||
Path: "p2b",
|
||||
SignatureSecret: inboundSecret,
|
||||
},
|
||||
{
|
||||
// A scheme this build does not know: described as
|
||||
// unavailable, never echoed back.
|
||||
Path: "p3",
|
||||
SignatureScheme: database.SignatureScheme("stripe"),
|
||||
SignatureSecret: inboundSecret,
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, views, 5)
|
||||
|
||||
assert.True(t, views[0].Configured)
|
||||
assert.Equal(t, "GitHub", views[0].SchemeLabel)
|
||||
assert.Equal(t, "X-Hub-Signature-256", views[0].SchemeHeader)
|
||||
|
||||
assert.False(t, views[1].Configured)
|
||||
assert.Equal(t, "not verified", views[1].SchemeLabel)
|
||||
assert.Empty(t, views[1].SchemeHeader)
|
||||
|
||||
for _, v := range []handlers.EntrypointView{views[2], views[3]} {
|
||||
assert.False(t, v.Configured)
|
||||
assert.Equal(t, "misconfigured", v.SchemeLabel)
|
||||
assert.Empty(t, v.SchemeHeader)
|
||||
}
|
||||
|
||||
assert.True(t, views[4].Configured)
|
||||
assert.Equal(t, "(unavailable)", views[4].SchemeLabel)
|
||||
|
||||
// The struct has no field that could carry the secret, so this
|
||||
// fails to compile rather than fails at runtime if one is added
|
||||
// and populated. The assertion covers the labels it derives.
|
||||
for _, v := range views {
|
||||
assert.NotContains(t, v.SchemeLabel, inboundSecret)
|
||||
assert.NotContains(t, v.SchemeHeader, inboundSecret)
|
||||
assert.NotContains(t, string(v.Scheme), inboundSecret)
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,56 @@ package handlers
|
||||
|
||||
import (
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
)
|
||||
|
||||
// signatureUnavailable is what an entrypoint's scheme renders as when
|
||||
// the stored value is not one this build supports. The stored string
|
||||
// is never echoed as a fallback: it is operator-supplied and the row
|
||||
// is already in a state the receiver refuses, so the UI says so
|
||||
// rather than inventing a description for it.
|
||||
const signatureUnavailable = "(unavailable)"
|
||||
|
||||
// signatureNotVerified is the label for an entrypoint that performs
|
||||
// no inbound verification.
|
||||
const signatureNotVerified = "not verified"
|
||||
|
||||
// signatureMisconfigured is the label for a row holding one half of
|
||||
// the scheme/secret pair. The receiver answers every request to such
|
||||
// an entrypoint 500, so calling it "not verified" would describe a
|
||||
// receiver that is refusing everything as one that is accepting
|
||||
// everything. The form cannot create the state; a hand-edited
|
||||
// database or a downgrade past a scheme can.
|
||||
const signatureMisconfigured = "misconfigured"
|
||||
|
||||
// EntrypointView is the display-safe projection of an entrypoint for
|
||||
// the UI, in the same way delivery.TargetView is one for a target.
|
||||
// the UI. It deliberately has no secret field, so no template —
|
||||
// present or future — can render the shared secret, in the same way
|
||||
// delivery.TargetView keeps a target's stored credential away from
|
||||
// one.
|
||||
type EntrypointView struct {
|
||||
ID string
|
||||
Path string
|
||||
Description string
|
||||
Active bool
|
||||
|
||||
// Configured reports whether inbound requests to this entrypoint
|
||||
// are verified.
|
||||
Configured bool
|
||||
|
||||
// Scheme is the stored scheme, carried so the form can preselect
|
||||
// it. It names an algorithm, not a secret.
|
||||
Scheme database.SignatureScheme
|
||||
|
||||
// SchemeLabel and SchemeHeader describe the configured scheme for
|
||||
// display: the sender's name, and the header its signature
|
||||
// arrives in.
|
||||
SchemeLabel string
|
||||
SchemeHeader string
|
||||
}
|
||||
|
||||
// NewEntrypointViews projects entrypoints for rendering.
|
||||
// NewEntrypointViews projects entrypoints for rendering, dropping the
|
||||
// shared secret on the way.
|
||||
func NewEntrypointViews(
|
||||
entrypoints []database.Entrypoint,
|
||||
) []EntrypointView {
|
||||
@@ -22,12 +60,31 @@ func NewEntrypointViews(
|
||||
for i := range entrypoints {
|
||||
e := &entrypoints[i]
|
||||
|
||||
views = append(views, EntrypointView{
|
||||
ID: e.ID,
|
||||
Path: e.Path,
|
||||
Description: e.Description,
|
||||
Active: e.Active,
|
||||
})
|
||||
view := EntrypointView{
|
||||
ID: e.ID,
|
||||
Path: e.Path,
|
||||
Description: e.Description,
|
||||
Active: e.Active,
|
||||
Configured: e.SignatureConfigured(),
|
||||
Scheme: e.SignatureScheme,
|
||||
SchemeLabel: signatureNotVerified,
|
||||
SchemeHeader: "",
|
||||
}
|
||||
|
||||
switch {
|
||||
case view.Configured:
|
||||
view.SchemeLabel = signatureUnavailable
|
||||
|
||||
info, ok := signature.Info(e.SignatureScheme)
|
||||
if ok {
|
||||
view.SchemeLabel = info.Label
|
||||
view.SchemeHeader = info.Header
|
||||
}
|
||||
case e.SignatureHalfConfigured():
|
||||
view.SchemeLabel = signatureMisconfigured
|
||||
}
|
||||
|
||||
views = append(views, view)
|
||||
}
|
||||
|
||||
return views
|
||||
|
||||
@@ -84,6 +84,10 @@ const resubmitColumns = "id, entrypoint_id, method, headers, " +
|
||||
// is the stored EVENT. The response bodies and headers the original
|
||||
// deliveries received stay where they are.
|
||||
//
|
||||
// Inbound signature verification is deliberately not re-run. There is
|
||||
// no inbound signature to check on a copy the operator submits; the
|
||||
// route is authenticated and CSRF-protected as an operator action.
|
||||
//
|
||||
// Resubmitting the same event repeatedly is supported and is the point
|
||||
// of the feature, so replay's in-flight refusal is deliberately not
|
||||
// applied here. The route's rate limit is what bounds a held-down
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// The footer in base.html falls back to the literal "dev" when the
|
||||
// template data carries no version, which is what every page rendered
|
||||
// while nothing supplied one. The operator uses the footer to tell
|
||||
// which build is live, so it has to carry the stamped value.
|
||||
func TestFooterReportsStampedVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
g *globals.Globals
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &g)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
g.Version = "v9.9.9-test"
|
||||
|
||||
html := renderPage(t, h, sess, "login.html", map[string]any{
|
||||
dataKeyError: "",
|
||||
})
|
||||
|
||||
assert.Contains(t, html, "<span>v9.9.9-test</span>")
|
||||
assert.NotContains(t, html, "<span>dev</span>")
|
||||
}
|
||||
@@ -184,7 +184,6 @@ type UserInfo struct {
|
||||
type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
CSRFToken string
|
||||
Version string
|
||||
Data any
|
||||
}
|
||||
|
||||
@@ -235,16 +234,9 @@ func (s *Handlers) renderTemplate(
|
||||
userInfo := s.getUserInfo(r)
|
||||
csrfToken := middleware.CSRFToken(r)
|
||||
|
||||
// The footer in base.html renders .Version. Every page reaches it
|
||||
// through here, so this is the one place that has to supply it;
|
||||
// left unset, the footer falls back to its literal "dev" and the
|
||||
// UI reports a build that is not the one running.
|
||||
version := s.params.Globals.Version
|
||||
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
m["User"] = userInfo
|
||||
m["CSRFToken"] = csrfToken
|
||||
m["Version"] = version
|
||||
s.executeTemplate(w, tmpl, m)
|
||||
|
||||
return
|
||||
@@ -253,7 +245,6 @@ func (s *Handlers) renderTemplate(
|
||||
wrapper := templateDataWrapper{
|
||||
User: userInfo,
|
||||
CSRFToken: csrfToken,
|
||||
Version: version,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
@@ -75,77 +73,6 @@ func seedTarget(
|
||||
return tgt
|
||||
}
|
||||
|
||||
// errInjectedDelete is the failure failDeleteOnTable reports
|
||||
// from a delete statement.
|
||||
var errInjectedDelete = errors.New("injected delete failure")
|
||||
|
||||
// seedEntrypoint inserts an entrypoint for a webhook.
|
||||
func seedEntrypoint(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
webhookID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
ep := &database.Entrypoint{
|
||||
WebhookID: webhookID,
|
||||
Path: "ep-" + webhookID,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Omit(clause.Associations).Create(ep).Error,
|
||||
)
|
||||
}
|
||||
|
||||
// countRows counts the live (not soft-deleted) rows of a model
|
||||
// matching column = value.
|
||||
func countRows(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
model any,
|
||||
column, value string,
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
var n int64
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Model(model).
|
||||
Where(column+" = ?", value).
|
||||
Count(&n).Error,
|
||||
)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// failDeleteOnTable makes every delete against the named table
|
||||
// fail the way a database-level error does: the statement
|
||||
// reports an error but leaves the surrounding transaction
|
||||
// usable, so a caller that does not check it can go on to
|
||||
// commit the statements that did succeed.
|
||||
func failDeleteOnTable(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
table string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, db.DB().Callback().Delete().
|
||||
Before("gorm:delete").
|
||||
Register(
|
||||
"test:fail_delete_"+table,
|
||||
func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == table {
|
||||
_ = tx.AddError(errInjectedDelete)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// archivePathFor returns the archive database path the
|
||||
// delivery engine would use for a webhook: beside the webhook's
|
||||
// event database in the data directory.
|
||||
@@ -282,159 +209,6 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceDelete_FailedDeleteKeepsEverything proves
|
||||
// that a failing delete statement loses nothing: the
|
||||
// configuration is rolled back whole, the event database
|
||||
// survives, and the operator is told the deletion failed
|
||||
// instead of being redirected as though it worked.
|
||||
func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
mgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &mgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
seedEntrypoint(t, db, wh.ID)
|
||||
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
require.NoError(t, mgr.CreateDB(wh.ID))
|
||||
|
||||
eventDBPath := mgr.DBPath(wh.ID)
|
||||
require.FileExists(t, eventDBPath)
|
||||
|
||||
// The entrypoint delete runs first and succeeds; the target
|
||||
// delete then fails, which is what the whole transaction has
|
||||
// to be rolled back over.
|
||||
failDeleteOnTable(t, db, "targets")
|
||||
|
||||
cookies := authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusInternalServerError, w.Code,
|
||||
"a failed deletion must be reported, not redirected",
|
||||
)
|
||||
assert.Empty(
|
||||
t, w.Header().Get("Location"),
|
||||
"a failed deletion must not redirect to /sources",
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, int64(1),
|
||||
countRows(t, db, &database.Webhook{}, "id", wh.ID),
|
||||
"the webhook must survive a failed deletion",
|
||||
)
|
||||
assert.Equal(
|
||||
t, int64(1),
|
||||
countRows(
|
||||
t, db, &database.Entrypoint{}, "webhook_id", wh.ID,
|
||||
),
|
||||
"the entrypoint delete must be rolled back",
|
||||
)
|
||||
assert.Equal(
|
||||
t, int64(1),
|
||||
countRows(
|
||||
t, db, &database.Target{}, "webhook_id", wh.ID,
|
||||
),
|
||||
"the target must survive a failed deletion",
|
||||
)
|
||||
|
||||
assert.FileExists(
|
||||
t, eventDBPath,
|
||||
"event history must not be destroyed when the "+
|
||||
"configuration delete did not commit",
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceDelete_RemovesConfigAndEventDatabase is the
|
||||
// positive control for the rollback above: an ordinary deletion
|
||||
// still removes the webhook, its children and its event
|
||||
// database.
|
||||
func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
mgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &mgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
seedEntrypoint(t, db, wh.ID)
|
||||
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
||||
|
||||
require.NoError(t, mgr.CreateDB(wh.ID))
|
||||
|
||||
eventDBPath := mgr.DBPath(wh.ID)
|
||||
require.FileExists(t, eventDBPath)
|
||||
|
||||
cookies := authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/source/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/sources", w.Header().Get("Location"))
|
||||
|
||||
assert.Equal(
|
||||
t, int64(0),
|
||||
countRows(t, db, &database.Webhook{}, "id", wh.ID),
|
||||
)
|
||||
assert.Equal(
|
||||
t, int64(0),
|
||||
countRows(
|
||||
t, db, &database.Entrypoint{}, "webhook_id", wh.ID,
|
||||
),
|
||||
)
|
||||
assert.Equal(
|
||||
t, int64(0),
|
||||
countRows(
|
||||
t, db, &database.Target{}, "webhook_id", wh.ID,
|
||||
),
|
||||
)
|
||||
assert.NoFileExists(
|
||||
t, eventDBPath,
|
||||
"a successful deletion removes the event database",
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
|
||||
// proves that removing the last database target releases the
|
||||
// archive writer.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
)
|
||||
|
||||
// WebhookListItem holds data for the webhook list view.
|
||||
@@ -442,13 +443,16 @@ func (h *Handlers) renderSourceDetail(
|
||||
// receivers; html/template cannot address a value stored in a map.
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: &webhook,
|
||||
// Targets are projected to a display-safe view: a
|
||||
// target's stored config blob holds a credential, and it
|
||||
// must never reach a template.
|
||||
"Entrypoints": NewEntrypointViews(entrypoints),
|
||||
"Targets": delivery.NewTargetViews(targets),
|
||||
"Events": events,
|
||||
"BaseURL": scheme + "://" + host,
|
||||
// Entrypoints and targets are both projected to
|
||||
// display-safe views: an entrypoint carries the shared
|
||||
// secret its senders sign with and a target's stored
|
||||
// config blob holds a credential, and neither must ever
|
||||
// reach a template.
|
||||
"Entrypoints": NewEntrypointViews(entrypoints),
|
||||
"Targets": delivery.NewTargetViews(targets),
|
||||
"SignatureSchemes": signature.Schemes(),
|
||||
"Events": events,
|
||||
"BaseURL": scheme + "://" + host,
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_detail.html", data)
|
||||
@@ -621,26 +625,42 @@ func (h *Handlers) deleteWebhookResources(
|
||||
webhook database.Webhook,
|
||||
userID string,
|
||||
) {
|
||||
// The configuration delete commits before the event database
|
||||
// is touched. No transaction spans the main database and the
|
||||
// filesystem, so one side has to go first: committing the
|
||||
// configuration first means a later failure leaves an unused
|
||||
// event database file on disk, while removing the event
|
||||
// database first would mean a failed commit destroys the
|
||||
// history of a webhook that still exists. A leftover file can
|
||||
// be removed by hand; deleted history cannot be recovered.
|
||||
err := h.commitWebhookDeletion(&webhook)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to delete webhook", err)
|
||||
tx := h.db.DB().Begin()
|
||||
if tx.Error != nil {
|
||||
h.log.Error(
|
||||
"failed to begin transaction",
|
||||
"error", tx.Error,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.log.Info(
|
||||
"webhook deleted",
|
||||
"webhook_id", webhook.ID,
|
||||
"user_id", userID,
|
||||
)
|
||||
tx.Where(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Delete(&database.Entrypoint{})
|
||||
|
||||
tx.Where(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Delete(&database.Target{})
|
||||
|
||||
tx.Delete(&webhook)
|
||||
|
||||
err := tx.Commit().Error
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to commit deletion", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Release the delivery engine's per-webhook archiving state
|
||||
// so a deleted webhook's archive writer (and any handle open
|
||||
@@ -651,63 +671,22 @@ func (h *Handlers) deleteWebhookResources(
|
||||
|
||||
err = h.dbMgr.DeleteDB(webhook.ID)
|
||||
if err != nil {
|
||||
// The configuration is committed, so the webhook is gone,
|
||||
// but its event database file is still on disk with
|
||||
// nothing referencing it. Report the failure rather than
|
||||
// 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,
|
||||
h.log.Error(
|
||||
"failed to delete webhook event database",
|
||||
"webhook_id", webhook.ID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.log.Info(
|
||||
"webhook deleted",
|
||||
"webhook_id", webhook.ID,
|
||||
"user_id", userID,
|
||||
)
|
||||
|
||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||
// targets and the webhook row in one transaction. Every
|
||||
// statement is checked and any failure rolls the whole
|
||||
// transaction back, so a caller that gets an error knows the
|
||||
// configuration is untouched and the event database must be
|
||||
// left alone.
|
||||
func (h *Handlers) commitWebhookDeletion(
|
||||
webhook *database.Webhook,
|
||||
) error {
|
||||
tx := h.db.DB().Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
|
||||
err := tx.Where(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Delete(&database.Entrypoint{}).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Where(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Delete(&database.Target{}).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Delete(webhook).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// evictArchiveWriter asks the delivery engine to drop its
|
||||
// cached archive writer for a webhook, closing the archive file
|
||||
// handle.
|
||||
@@ -1261,6 +1240,145 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleEntrypointSecret sets, rotates or removes the shared secret
|
||||
// an entrypoint verifies inbound requests with.
|
||||
//
|
||||
// Setting and rotating are the same operation: the form always takes
|
||||
// the secret afresh and the stored value is never sent to the browser
|
||||
// to be edited, so there is no path by which the page can display a
|
||||
// credential it holds. Rotation is therefore "submit the new secret",
|
||||
// and the operator already has that value — both supported senders
|
||||
// require them to enter the same string on the sender's side, so
|
||||
// there is no generated value for webhooker to reveal once.
|
||||
func (h *Handlers) HandleEntrypointSecret() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
webhook, ok := h.ownedWebhook(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// The body size cap is enforced by the MaxBodySize
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var entrypoint database.Entrypoint
|
||||
|
||||
err = h.db.DB().Where(
|
||||
"id = ? AND webhook_id = ?",
|
||||
chi.URLParam(r, "entrypointID"), webhook.ID,
|
||||
).First(&entrypoint).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.applyEntrypointSecret(w, r, &entrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
// applyEntrypointSecret validates the submitted scheme and secret and
|
||||
// stores them.
|
||||
//
|
||||
// A scheme this build does not support is a 400, never a stored value
|
||||
// the receiver would later have to interpret: the receiver fails such
|
||||
// a row closed, so letting one be created would take the entrypoint
|
||||
// offline through a form that reported success.
|
||||
func (h *Handlers) applyEntrypointSecret(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
entrypoint *database.Entrypoint,
|
||||
) {
|
||||
// PostFormValue, not FormValue: a credential must come from the
|
||||
// body. FormValue falls back to the query string, and the request
|
||||
// line — unlike the body — is what logs, proxies, Referer headers
|
||||
// and error trackers record.
|
||||
scheme := database.SignatureScheme(
|
||||
r.PostFormValue("signature_scheme"),
|
||||
)
|
||||
|
||||
// Surrounding whitespace is stripped, because a secret pasted from
|
||||
// a password manager routinely carries some and the resulting
|
||||
// mismatch is undiagnosable from the sender's side. A secret whose
|
||||
// own first or last character is a space cannot be stored; the
|
||||
// README says so.
|
||||
secret := strings.TrimSpace(r.PostFormValue("secret"))
|
||||
|
||||
if !signature.Supported(scheme) {
|
||||
http.Error(
|
||||
w, "Invalid signature scheme",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if scheme == database.SignatureSchemeNone {
|
||||
// Turning verification off drops the secret with it: a stored
|
||||
// credential nothing reads is one more copy to leak, and
|
||||
// Verify refuses that pairing in any case.
|
||||
secret = ""
|
||||
} else if secret == "" {
|
||||
http.Error(
|
||||
w,
|
||||
"A shared secret is required for this signature scheme.",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.storeEntrypointSecret(w, r, entrypoint, scheme, secret)
|
||||
}
|
||||
|
||||
// storeEntrypointSecret writes a validated scheme and secret to an
|
||||
// entrypoint and returns the operator to the webhook page.
|
||||
func (h *Handlers) storeEntrypointSecret(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
entrypoint *database.Entrypoint,
|
||||
scheme database.SignatureScheme,
|
||||
secret string,
|
||||
) {
|
||||
// Updates with a map rather than a struct: a struct update skips
|
||||
// zero values, and the empty pair is exactly what has to be
|
||||
// written when verification is being turned off.
|
||||
err := h.db.DB().Model(entrypoint).Updates(map[string]any{
|
||||
"signature_scheme": scheme,
|
||||
"signature_secret": secret,
|
||||
}).Error
|
||||
if err != nil {
|
||||
// The error is logged by serverError; GORM's error text
|
||||
// carries the statement, not the bound values, so the secret
|
||||
// does not travel with it.
|
||||
h.serverError(
|
||||
w, "failed to update entrypoint signature", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.log.Info(
|
||||
"entrypoint signature configuration updated",
|
||||
"entrypoint_id", entrypoint.ID,
|
||||
"webhook_id", entrypoint.WebhookID,
|
||||
"scheme", string(scheme),
|
||||
)
|
||||
|
||||
http.Redirect(
|
||||
w, r,
|
||||
"/source/"+entrypoint.WebhookID,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
// HandleTargetCreate handles adding a new target to a webhook.
|
||||
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
)
|
||||
|
||||
// Template data keys the page templates read. The handlers package has
|
||||
@@ -268,15 +269,16 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
||||
|
||||
body := renderPage(t, h, sess, "source_detail.html", map[string]any{
|
||||
dataKeyWebhook: webhook,
|
||||
// The handler passes projected views, never raw rows — a
|
||||
// target carries its stored credential — so the test data
|
||||
// has that same shape.
|
||||
// The handler passes projected views, never raw rows — an
|
||||
// entrypoint carries its shared secret and a target its
|
||||
// stored credential — so the test data has that same shape.
|
||||
"Entrypoints": handlers.NewEntrypointViews(
|
||||
[]database.Entrypoint{entrypoint},
|
||||
),
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
"Events": []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
"SignatureSchemes": signature.Schemes(),
|
||||
"Events": []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
})
|
||||
|
||||
assert.Contains(
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -70,12 +72,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// processWebhookRequest reads the body, serializes headers, loads
|
||||
// targets, and delivers the event.
|
||||
//
|
||||
// Nothing about the request itself is authenticated: the entrypoint
|
||||
// UUID in the path is the credential, and reaching here means it
|
||||
// matched an active entrypoint.
|
||||
// processWebhookRequest reads the body, verifies the sender,
|
||||
// serializes headers, loads targets, and delivers the event.
|
||||
func (h *Handlers) processWebhookRequest(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -86,7 +84,26 @@ func (h *Handlers) processWebhookRequest(
|
||||
return
|
||||
}
|
||||
|
||||
headersJSON, err := json.Marshal(r.Header)
|
||||
// Before anything is written. An unverified request must leave no
|
||||
// event row, no delivery row and no delivery task behind, so this
|
||||
// sits above every write rather than inside the transaction that
|
||||
// performs them. It has to sit below the body read because the
|
||||
// signature is computed over the body; readWebhookBody is what
|
||||
// bounds that read, so an unauthenticated sender still cannot make
|
||||
// the process hold more than the 1 MB cap.
|
||||
if !h.verifyInboundSignature(w, entrypoint, r.Header, body) {
|
||||
return
|
||||
}
|
||||
|
||||
// These headers are about to be stored verbatim and handed to
|
||||
// every delivery target, so the scheme's credential comes out
|
||||
// first. Under GitLab's scheme the header is the shared secret
|
||||
// itself, and leaving it in would hand the ability to forge
|
||||
// signed requests to exactly the parties the signature is meant
|
||||
// to exclude.
|
||||
headersJSON, err := json.Marshal(
|
||||
signature.SanitizeHeaders(&entrypoint, r.Header),
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to serialize headers", err)
|
||||
|
||||
@@ -105,6 +122,63 @@ func (h *Handlers) processWebhookRequest(
|
||||
)
|
||||
}
|
||||
|
||||
// verifyInboundSignature authenticates the request against the
|
||||
// entrypoint's configured secret, reporting false once it has written
|
||||
// the response.
|
||||
//
|
||||
// An entrypoint with no secret configured is not checked and this
|
||||
// returns true, which is the unchanged behaviour every existing
|
||||
// entrypoint keeps.
|
||||
//
|
||||
// A configuration that cannot be applied — an unknown scheme, or one
|
||||
// half of the pair missing — is a 500, not a 401: the request may well
|
||||
// be authentic, and calling it unauthorized would tell a legitimate
|
||||
// sender to go fix its own signing. Either way it is refused. Failing
|
||||
// open here would mean an entrypoint the operator has protected
|
||||
// quietly accepting anything.
|
||||
func (h *Handlers) verifyInboundSignature(
|
||||
w http.ResponseWriter,
|
||||
entrypoint database.Entrypoint,
|
||||
header http.Header,
|
||||
body []byte,
|
||||
) bool {
|
||||
err := signature.Verify(&entrypoint, header, body)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if errors.Is(err, signature.ErrConfig) {
|
||||
h.log.Error(
|
||||
"entrypoint signature configuration cannot be applied",
|
||||
"entrypoint_id", entrypoint.ID,
|
||||
"webhook_id", entrypoint.WebhookID,
|
||||
"error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Every field here is bounded and none is client-chosen: the ids
|
||||
// are ours, the scheme is one of a fixed set, and the error is a
|
||||
// static string carrying no part of the secret or of what the
|
||||
// client presented. Reaching this line also requires a real
|
||||
// entrypoint UUID, so it is not a line a stranger can drive.
|
||||
h.log.Warn(
|
||||
"inbound signature verification failed",
|
||||
"entrypoint_id", entrypoint.ID,
|
||||
"webhook_id", entrypoint.WebhookID,
|
||||
"scheme", string(entrypoint.SignatureScheme),
|
||||
"error", err,
|
||||
)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// loadActiveTargets returns all active targets for a webhook.
|
||||
func (h *Handlers) loadActiveTargets(
|
||||
webhookID string,
|
||||
|
||||
468
internal/handlers/webhook_signature_test.go
Normal file
468
internal/handlers/webhook_signature_test.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
)
|
||||
|
||||
const (
|
||||
// inboundSecret is the shared secret the signed-receiver tests
|
||||
// configure on their entrypoint. It doubles as a marker: no log
|
||||
// line and no rendered page may contain it.
|
||||
inboundSecret = "QQINBOUNDSECRETQQ"
|
||||
|
||||
// inboundBody is the payload the sender signs.
|
||||
inboundBody = `{"zen":"Non-blocking is better than blocking."}`
|
||||
|
||||
// entrypointIDParam is the chi URL parameter naming an entrypoint.
|
||||
entrypointIDParam = "entrypointID"
|
||||
)
|
||||
|
||||
// hubSignature returns the X-Hub-Signature-256 value a GitHub sender
|
||||
// holding secret would send for inboundBody.
|
||||
func hubSignature(secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(inboundBody))
|
||||
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// seedSignedEntrypoint inserts an active entrypoint for a webhook
|
||||
// with the given signature configuration and returns it.
|
||||
func seedSignedEntrypoint(
|
||||
t *testing.T,
|
||||
db *database.Database,
|
||||
webhookID string,
|
||||
scheme database.SignatureScheme,
|
||||
secret string,
|
||||
) *database.Entrypoint {
|
||||
t.Helper()
|
||||
|
||||
ep := &database.Entrypoint{
|
||||
WebhookID: webhookID,
|
||||
Path: "path-" + webhookID,
|
||||
Description: "signed",
|
||||
Active: true,
|
||||
SignatureScheme: scheme,
|
||||
SignatureSecret: secret,
|
||||
}
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.DB().Omit(clause.Associations).Create(ep).Error,
|
||||
)
|
||||
|
||||
return ep
|
||||
}
|
||||
|
||||
// postToEntrypoint drives the real receiver handler at an
|
||||
// entrypoint's path with one optional header set.
|
||||
func postToEntrypoint(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
path, body, headerName, headerValue string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/webhook/"+path,
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if headerName != "" {
|
||||
req.Header.Set(headerName, headerValue)
|
||||
}
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("uuid", path)
|
||||
|
||||
req = req.WithContext(
|
||||
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleWebhook().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// storedEvents counts the event rows a webhook's per-webhook database
|
||||
// holds. A database that was never opened holds none, which is the
|
||||
// state a rejected request has to leave behind.
|
||||
func storedEvents(
|
||||
t *testing.T,
|
||||
mgr *database.WebhookDBManager,
|
||||
webhookID string,
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
if !mgr.DBExists(webhookID) {
|
||||
return 0
|
||||
}
|
||||
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var count int64
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.Model(&database.Event{}).
|
||||
Where("webhook_id = ?", webhookID).
|
||||
Count(&count).Error,
|
||||
)
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// storedEventHeaders reads back the Headers column of the single
|
||||
// event row a webhook's per-webhook database holds.
|
||||
//
|
||||
// It reads the database rather than an in-memory struct on purpose:
|
||||
// what matters is what an operator, a backup or the reaper's archive
|
||||
// would find on disk, not what the handler passed around.
|
||||
func storedEventHeaders(
|
||||
t *testing.T,
|
||||
mgr *database.WebhookDBManager,
|
||||
webhookID string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
require.True(t, mgr.DBExists(webhookID))
|
||||
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var events []database.Event
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.Where("webhook_id = ?", webhookID).
|
||||
Find(&events).Error,
|
||||
)
|
||||
require.Len(t, events, 1)
|
||||
|
||||
return events[0].Headers
|
||||
}
|
||||
|
||||
// signedReceiverCase is one inbound request against an entrypoint
|
||||
// with a given stored signature configuration.
|
||||
type signedReceiverCase struct {
|
||||
name string
|
||||
scheme database.SignatureScheme
|
||||
secret string
|
||||
headerName string
|
||||
headerValue string
|
||||
body string
|
||||
wantStatus int
|
||||
}
|
||||
|
||||
// signedReceiverCases covers each supported scheme with a valid
|
||||
// signature, an invalid one and none at all, plus the two states that
|
||||
// are not "a client got it wrong": an entrypoint with nothing
|
||||
// configured, and one whose stored configuration cannot be applied.
|
||||
func signedReceiverCases() []signedReceiverCase {
|
||||
return append(
|
||||
schemeReceiverCases(), unverifiedReceiverCases()...,
|
||||
)
|
||||
}
|
||||
|
||||
// schemeReceiverCases covers the two supported schemes.
|
||||
func schemeReceiverCases() []signedReceiverCase {
|
||||
return []signedReceiverCase{
|
||||
{
|
||||
name: "github valid",
|
||||
scheme: database.SignatureSchemeGitHub,
|
||||
secret: inboundSecret,
|
||||
headerName: signature.HeaderGitHub,
|
||||
headerValue: hubSignature(inboundSecret),
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "github wrong secret",
|
||||
scheme: database.SignatureSchemeGitHub,
|
||||
secret: inboundSecret,
|
||||
headerName: signature.HeaderGitHub,
|
||||
headerValue: hubSignature("wrong"),
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
// A digest that was valid for a different body: the
|
||||
// check is over the bytes as received.
|
||||
name: "github body tampered",
|
||||
scheme: database.SignatureSchemeGitHub,
|
||||
secret: inboundSecret,
|
||||
headerName: signature.HeaderGitHub,
|
||||
headerValue: hubSignature(inboundSecret),
|
||||
body: inboundBody + " ",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "github unsigned",
|
||||
scheme: database.SignatureSchemeGitHub,
|
||||
secret: inboundSecret,
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "gitlab valid",
|
||||
scheme: database.SignatureSchemeGitLab,
|
||||
secret: inboundSecret,
|
||||
headerName: signature.HeaderGitLab,
|
||||
headerValue: inboundSecret,
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "gitlab wrong token",
|
||||
scheme: database.SignatureSchemeGitLab,
|
||||
secret: inboundSecret,
|
||||
headerName: signature.HeaderGitLab,
|
||||
headerValue: "wrong",
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "gitlab unsigned",
|
||||
scheme: database.SignatureSchemeGitLab,
|
||||
secret: inboundSecret,
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// unverifiedReceiverCases covers the two entrypoint states that are
|
||||
// not about a client getting its signature wrong: nothing configured
|
||||
// at all, and a configuration the receiver cannot apply.
|
||||
func unverifiedReceiverCases() []signedReceiverCase {
|
||||
return []signedReceiverCase{
|
||||
{
|
||||
// The pass-through case. An entrypoint with nothing
|
||||
// configured is what every deployment already has, and
|
||||
// it must keep accepting unsigned requests so that an
|
||||
// upgrade does not lock an operator out of their own
|
||||
// receivers.
|
||||
name: "unconfigured accepts unsigned",
|
||||
scheme: database.SignatureSchemeNone,
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
// A stray signature header changes nothing when nothing
|
||||
// is configured to check it.
|
||||
name: "unconfigured ignores a stray header",
|
||||
scheme: database.SignatureSchemeNone,
|
||||
headerName: signature.HeaderGitHub,
|
||||
headerValue: "sha256=deadbeef",
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
// A scheme this build cannot apply, reachable only by
|
||||
// editing the database: refused, not waved through as
|
||||
// unverified.
|
||||
name: "unknown scheme fails closed",
|
||||
scheme: database.SignatureScheme("stripe"),
|
||||
secret: inboundSecret,
|
||||
headerName: signature.HeaderGitHub,
|
||||
headerValue: hubSignature(inboundSecret),
|
||||
body: inboundBody,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiverVerifiesConfiguredEntrypoints is the load-bearing test
|
||||
// for the feature: for each supported scheme a correctly signed
|
||||
// request is accepted and stored, and an incorrectly signed or
|
||||
// unsigned one is answered 401 having stored nothing.
|
||||
//
|
||||
// The event count is the half that matters most. A rejection that
|
||||
// still wrote a row would leave the receiver a place for a stranger
|
||||
// who knows a URL to deposit content, which is exactly what the
|
||||
// signature is there to prevent.
|
||||
//
|
||||
// The cases share one application and take a webhook each, rather
|
||||
// than each standing up its own: every newTestApp seeds an admin user
|
||||
// and so pays an Argon2id hash at 64 MB, and this package's test
|
||||
// budget is not large enough to spend one per table row.
|
||||
func TestReceiverVerifiesConfiguredEntrypoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
db *database.Database
|
||||
mgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &db, &mgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
for _, tc := range signedReceiverCases() {
|
||||
wh := seedWebhook(t, db)
|
||||
ep := seedSignedEntrypoint(
|
||||
t, db, wh.ID, tc.scheme, tc.secret,
|
||||
)
|
||||
|
||||
w := postToEntrypoint(
|
||||
t, h, ep.Path, tc.body,
|
||||
tc.headerName, tc.headerValue,
|
||||
)
|
||||
|
||||
assert.Equal(t, tc.wantStatus, w.Code, "case %s", tc.name)
|
||||
|
||||
want := int64(0)
|
||||
if tc.wantStatus == http.StatusOK {
|
||||
want = 1
|
||||
}
|
||||
|
||||
assert.Equal(
|
||||
t, want, storedEvents(t, mgr, wh.ID),
|
||||
"case %s: stored event rows after a %d response",
|
||||
tc.name, w.Code,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReceiverLogsNoSecret proves the rejection path does not write
|
||||
// the shared secret, or what the client presented, into the log. A
|
||||
// GitLab token arrives as the credential itself, so echoing the
|
||||
// header value would put a live secret in the log of every deployment
|
||||
// whose sender is briefly misconfigured.
|
||||
func TestReceiverLogsNoSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const presented = "QQPRESENTEDVALUEQQ"
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
h.SetLogForTest(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
ep := seedSignedEntrypoint(
|
||||
t, db, wh.ID,
|
||||
database.SignatureSchemeGitLab, inboundSecret,
|
||||
)
|
||||
|
||||
w := postToEntrypoint(
|
||||
t, h, ep.Path, inboundBody,
|
||||
signature.HeaderGitLab, presented,
|
||||
)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
// The rejection is recorded at all — a silent 401 leaves an
|
||||
// operator no way to see a sender failing to authenticate.
|
||||
assert.Contains(t, buf.String(), "verification failed")
|
||||
assert.NotContains(t, buf.String(), inboundSecret)
|
||||
assert.NotContains(t, buf.String(), presented)
|
||||
}
|
||||
|
||||
// TestReceiverDoesNotStoreInboundCredential proves an accepted
|
||||
// request leaves no copy of the shared secret in the event store.
|
||||
//
|
||||
// GitLab's X-Gitlab-Token is the credential itself, not a digest
|
||||
// over the request. Stored headers are read back by the UI, copied
|
||||
// into every backup and archive, and handed verbatim to every
|
||||
// delivery target, so a stored token is the entrypoint's only
|
||||
// authentication control disclosed to precisely the parties it
|
||||
// exists to exclude.
|
||||
//
|
||||
// The two cases share one application: every newTestApp seeds an
|
||||
// admin user and pays an Argon2id hash at 64 MB, and this package's
|
||||
// test budget does not stretch to one per case.
|
||||
func TestReceiverDoesNotStoreInboundCredential(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
db *database.Database
|
||||
mgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &db, &mgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
gitlab := seedWebhook(t, db)
|
||||
gitlabEP := seedSignedEntrypoint(
|
||||
t, db, gitlab.ID,
|
||||
database.SignatureSchemeGitLab, inboundSecret,
|
||||
)
|
||||
|
||||
w := postToEntrypoint(
|
||||
t, h, gitlabEP.Path, inboundBody,
|
||||
signature.HeaderGitLab, inboundSecret,
|
||||
)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
stored := storedEventHeaders(t, mgr, gitlab.ID)
|
||||
|
||||
assert.NotContains(
|
||||
t, stored, inboundSecret,
|
||||
"the shared secret must not be persisted",
|
||||
)
|
||||
assert.NotContains(
|
||||
t, stored, signature.HeaderGitLab,
|
||||
"the credential header must not be persisted at all",
|
||||
)
|
||||
|
||||
// Everything else the sender set is still there. A fix that
|
||||
// stored no headers would satisfy the assertions above while
|
||||
// discarding the record the receiver exists to keep.
|
||||
assert.Contains(t, stored, "Content-Type")
|
||||
|
||||
// A GitHub digest is an HMAC over the body, so the key cannot be
|
||||
// recovered from it and it stays: the stripping is scoped to
|
||||
// what actually carries the secret.
|
||||
github := seedWebhook(t, db)
|
||||
githubEP := seedSignedEntrypoint(
|
||||
t, db, github.ID,
|
||||
database.SignatureSchemeGitHub, inboundSecret,
|
||||
)
|
||||
|
||||
w = postToEntrypoint(
|
||||
t, h, githubEP.Path, inboundBody,
|
||||
signature.HeaderGitHub, hubSignature(inboundSecret),
|
||||
)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
stored = storedEventHeaders(t, mgr, github.ID)
|
||||
|
||||
assert.Contains(t, stored, signature.HeaderGitHub)
|
||||
assert.NotContains(t, stored, inboundSecret)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
"sneak.berlin/go/webhooker/internal/reqtls"
|
||||
)
|
||||
|
||||
// CSRFToken retrieves the CSRF token from the request context.
|
||||
@@ -14,6 +13,13 @@ func CSRFToken(r *http.Request) string {
|
||||
return csrf.Token(r)
|
||||
}
|
||||
|
||||
// isClientTLS reports whether the client-facing connection uses TLS.
|
||||
// It checks for a direct TLS connection (r.TLS) or a TLS-terminating
|
||||
// reverse proxy that sets the standard X-Forwarded-Proto header.
|
||||
func isClientTLS(r *http.Request) bool {
|
||||
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
|
||||
// CSRF returns middleware that provides CSRF protection using the
|
||||
// gorilla/csrf library. The middleware uses the session authentication
|
||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||
@@ -21,10 +27,9 @@ func CSRFToken(r *http.Request) string {
|
||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||
// token receive a 403 Forbidden response.
|
||||
//
|
||||
// The middleware detects the client-facing transport protocol
|
||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||
// cookie also uses. This allows correct behavior in all deployment
|
||||
// scenarios:
|
||||
// The middleware detects the client-facing transport protocol per-request
|
||||
// using r.TLS and the X-Forwarded-Proto header. This allows correct
|
||||
// behavior in all deployment scenarios:
|
||||
//
|
||||
// - Direct HTTPS: strict Referer/Origin checks, Secure cookies.
|
||||
// - Behind a TLS-terminating reverse proxy: strict checks (the
|
||||
@@ -78,7 +83,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
httpCSRF := httpProtect(next)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if reqtls.IsTLS(r) {
|
||||
if isClientTLS(r) {
|
||||
// Client is on TLS (directly or via reverse proxy).
|
||||
// Use Secure cookies and strict Origin/Referer checks.
|
||||
tlsCSRF.ServeHTTP(w, r)
|
||||
|
||||
@@ -297,176 +297,55 @@ func TestCSRFToken_NoMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
// --- TLS Detection Tests ---
|
||||
//
|
||||
// The predicate itself is tested in internal/reqtls. What is tested
|
||||
// here is the consequence that actually matters: which of the two
|
||||
// gorilla/csrf instances a request is routed to.
|
||||
//
|
||||
// The two are told apart behaviourally rather than by inspection. On
|
||||
// the STRICT (TLS) instance, a state-changing request carrying no
|
||||
// Origin header must supply a Referer -- gorilla/csrf rejects it with
|
||||
// ErrNoReferer before it ever looks at the token, to defend a
|
||||
// TLS site against an HTTP machine-in-the-middle injecting a form. On
|
||||
// the RELAXED (plaintext) instance that check is skipped and a valid
|
||||
// token is enough. So: valid token, no Origin, no Referer, and the
|
||||
// outcome names the instance.
|
||||
//
|
||||
// Landing on the relaxed instance for a genuinely-HTTPS deployment is
|
||||
// the defect: an exact == "https" comparison did exactly that for the
|
||||
// uppercase and comma-appended spellings below.
|
||||
|
||||
// csrfTookStrictPath reports whether the CSRF middleware routed a
|
||||
// request with the given transport to the strict instance. It also
|
||||
// asserts the CSRF cookie's Secure attribute agrees, since the two are
|
||||
// set by the same choice and must never disagree.
|
||||
func csrfTookStrictPath(
|
||||
t *testing.T,
|
||||
env string,
|
||||
directTLS bool,
|
||||
fwdProto string,
|
||||
) bool {
|
||||
t.Helper()
|
||||
|
||||
m, _ := testMiddleware(t, env)
|
||||
csrfMW := m.CSRF()
|
||||
|
||||
newReq := func(method string) *http.Request {
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), method,
|
||||
"http://example.com/form", nil,
|
||||
)
|
||||
|
||||
if directTLS {
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
|
||||
if fwdProto != "" {
|
||||
r.Header.Set("X-Forwarded-Proto", fwdProto)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
token, cookies := csrfGetToken(t, csrfMW, newReq(http.MethodGet))
|
||||
|
||||
// Deliberately no Origin and no Referer: that is what makes the
|
||||
// two instances distinguishable.
|
||||
called, code := csrfPostWithToken(
|
||||
t, csrfMW, newReq(http.MethodPost), token, cookies,
|
||||
)
|
||||
|
||||
strict := !called
|
||||
|
||||
if strict {
|
||||
assert.Equal(
|
||||
t, http.StatusForbidden, code,
|
||||
"the strict instance rejects a Referer-less POST",
|
||||
)
|
||||
}
|
||||
|
||||
for _, c := range cookies {
|
||||
if c.Name == csrfCookieName {
|
||||
assert.Equal(
|
||||
t, strict, c.Secure,
|
||||
"the CSRF cookie's Secure attribute and the "+
|
||||
"chosen instance come from one decision "+
|
||||
"and must agree",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return strict
|
||||
}
|
||||
|
||||
// TestCSRF_ForwardedProtoSpellingsTakeStrictPath runs the header
|
||||
// spellings a real proxy emits through the middleware. The environment
|
||||
// is dev -- the DEFAULT when WEBHOOKER_ENVIRONMENT is unset -- to pin
|
||||
// that the routing is a per-request transport decision and owes
|
||||
// nothing to configuration.
|
||||
func TestCSRF_ForwardedProtoSpellingsTakeStrictPath(t *testing.T) {
|
||||
func TestIsClientTLS_DirectTLS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
strict bool
|
||||
why string
|
||||
}{
|
||||
{
|
||||
name: "lowercase",
|
||||
header: "https",
|
||||
strict: true,
|
||||
why: "the ordinary spelling",
|
||||
},
|
||||
{
|
||||
name: "uppercase",
|
||||
header: "HTTPS",
|
||||
strict: true,
|
||||
why: "the header value is a case-insensitive token",
|
||||
},
|
||||
{
|
||||
name: "chain with plaintext inner hop",
|
||||
header: "https, http",
|
||||
strict: true,
|
||||
why: "a chained proxy appends its hop; the leftmost " +
|
||||
"element is the browser's connection",
|
||||
},
|
||||
{
|
||||
name: "chain of two TLS hops",
|
||||
header: "https,https",
|
||||
strict: true,
|
||||
why: "appended chain with no space after the comma",
|
||||
},
|
||||
{
|
||||
name: "trailing space",
|
||||
header: "https ",
|
||||
strict: true,
|
||||
why: "whitespace is not part of the token",
|
||||
},
|
||||
{
|
||||
name: "plaintext",
|
||||
header: "http",
|
||||
strict: false,
|
||||
why: "the negative control: the proxy reports a " +
|
||||
"plaintext client connection",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, tc.strict,
|
||||
csrfTookStrictPath(
|
||||
t, config.EnvironmentDev, false, tc.header,
|
||||
),
|
||||
"X-Forwarded-Proto %q: %s", tc.header, tc.why,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSRF_DirectTLSTakesStrictPath covers the no-proxy TLS
|
||||
// deployment, and TestCSRF_PlaintextTakesRelaxedPath the no-proxy
|
||||
// plaintext one -- the local development case that must keep working.
|
||||
func TestCSRF_DirectTLSTakesStrictPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
|
||||
assert.True(
|
||||
t,
|
||||
csrfTookStrictPath(t, config.EnvironmentDev, true, ""),
|
||||
"a request that arrived over TLS takes the strict path",
|
||||
t, middleware.IsClientTLS(r),
|
||||
"should detect direct TLS connection",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCSRF_PlaintextTakesRelaxedPath(t *testing.T) {
|
||||
func TestIsClientTLS_XForwardedProto(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
assert.True(
|
||||
t, middleware.IsClientTLS(r),
|
||||
"should detect TLS via X-Forwarded-Proto",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsClientTLS_PlaintextHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
assert.False(
|
||||
t,
|
||||
csrfTookStrictPath(t, config.EnvironmentProd, false, ""),
|
||||
"no TLS and no proxy header is plaintext, in any environment",
|
||||
t, middleware.IsClientTLS(r),
|
||||
"should detect plaintext HTTP",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsClientTLS_XForwardedProtoHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Forwarded-Proto", "http")
|
||||
|
||||
assert.False(
|
||||
t, middleware.IsClientTLS(r),
|
||||
"should detect plaintext when X-Forwarded-Proto is http",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ func ClientKeyForTest(m *Middleware, r *http.Request) string {
|
||||
return m.clientKey(r)
|
||||
}
|
||||
|
||||
// IsClientTLS exposes isClientTLS for testing.
|
||||
func IsClientTLS(r *http.Request) bool {
|
||||
return isClientTLS(r)
|
||||
}
|
||||
|
||||
// LoginRateLimitConst exposes the loginRateLimit constant: the
|
||||
// number of FAILED login attempts one client may make against one
|
||||
// submitted username per interval.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Package reqtls answers one question, in one place, for the whole
|
||||
// application: did this request reach the service over TLS?
|
||||
//
|
||||
// It exists because that question used to be answered independently in
|
||||
// several packages, by hand, and the answers disagreed. The session
|
||||
// cookie's Secure attribute was decided at startup from the configured
|
||||
// environment while the CSRF cookie's was decided per-request, so a
|
||||
// deployment behind a TLS proxy in the default environment emitted one
|
||||
// Secure cookie and one non-Secure cookie on the same response.
|
||||
// Everything kept working, which is exactly why nobody noticed.
|
||||
//
|
||||
// Any code that needs a scheme or a Secure flag must call IsTLS rather
|
||||
// than reading the request itself.
|
||||
package reqtls
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forwardedProtoHeader is the de-facto standard header by which a
|
||||
// TLS-terminating reverse proxy reports the protocol the CLIENT used.
|
||||
const forwardedProtoHeader = "X-Forwarded-Proto"
|
||||
|
||||
// IsTLS reports whether the client-facing connection uses TLS: either
|
||||
// the request arrived over TLS directly, or a reverse proxy terminated
|
||||
// TLS and said so in X-Forwarded-Proto.
|
||||
//
|
||||
// The header is only as trustworthy as whatever sits in front of the
|
||||
// listener. A proxy that overwrites it -- which is what the deployment
|
||||
// documentation requires -- makes it authoritative; a listener exposed
|
||||
// directly to clients lets any client assert it. That is the same
|
||||
// exposure every X-Forwarded-* consumer carries.
|
||||
func IsTLS(r *http.Request) bool {
|
||||
return r.TLS != nil || forwardedProto(r) == "https"
|
||||
}
|
||||
|
||||
// forwardedProto reduces X-Forwarded-Proto to a bare, comparable
|
||||
// protocol token, or "" when the header is absent or blank.
|
||||
//
|
||||
// Two shapes that real infrastructure emits do not survive an exact
|
||||
// comparison against "https", and both name a TLS client connection:
|
||||
//
|
||||
// - "HTTPS", because the header value is a case-insensitive token and
|
||||
// nothing obliges a proxy to emit it lowercased.
|
||||
// - "https, http", because a proxy chained behind another proxy
|
||||
// APPENDS its own hop instead of replacing the value. As with
|
||||
// X-Forwarded-For, the leftmost element is the one nearest the
|
||||
// client, so it is the element that describes the browser's
|
||||
// connection -- the only hop a cookie's Secure attribute is about.
|
||||
//
|
||||
// Landing on the plaintext path for either of those spellings is not a
|
||||
// cosmetic error: it stops gorilla/csrf enforcing the strict Referer
|
||||
// check on a site that genuinely is HTTPS.
|
||||
func forwardedProto(r *http.Request) string {
|
||||
first, _, _ := strings.Cut(r.Header.Get(forwardedProtoHeader), ",")
|
||||
|
||||
return strings.ToLower(strings.TrimSpace(first))
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package reqtls_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"sneak.berlin/go/webhooker/internal/reqtls"
|
||||
)
|
||||
|
||||
// newReq builds a plaintext request with no forwarding headers.
|
||||
func newReq(t *testing.T) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
return httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil,
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsTLS_DirectTLS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newReq(t)
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
|
||||
assert.True(
|
||||
t, reqtls.IsTLS(r),
|
||||
"a request that arrived over TLS is TLS",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsTLS_PlaintextNoHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.False(
|
||||
t, reqtls.IsTLS(newReq(t)),
|
||||
"no TLS connection and no header means plaintext",
|
||||
)
|
||||
}
|
||||
|
||||
// protoCase is one X-Forwarded-Proto spelling and the answer IsTLS
|
||||
// owes it.
|
||||
type protoCase struct {
|
||||
name string
|
||||
header string
|
||||
want bool
|
||||
why string
|
||||
}
|
||||
|
||||
// protoCases enumerates the header values real infrastructure emits.
|
||||
func protoCases() []protoCase {
|
||||
return append(protoTLSCases(), protoPlaintextCases()...)
|
||||
}
|
||||
|
||||
// protoTLSCases are the spellings that name a TLS client connection.
|
||||
// Every one but the first is a spelling an exact == "https"
|
||||
// comparison used to miss, silently downgrading a genuinely-HTTPS
|
||||
// deployment to the plaintext path.
|
||||
func protoTLSCases() []protoCase {
|
||||
return []protoCase{
|
||||
{
|
||||
name: "lowercase",
|
||||
header: "https",
|
||||
want: true,
|
||||
why: "the ordinary spelling",
|
||||
},
|
||||
{
|
||||
name: "uppercase",
|
||||
header: "HTTPS",
|
||||
want: true,
|
||||
why: "the value is a case-insensitive token; " +
|
||||
"nothing obliges a proxy to lowercase it",
|
||||
},
|
||||
{
|
||||
name: "mixed case",
|
||||
header: "HttpS",
|
||||
want: true,
|
||||
why: "case folding must be total, not just the two extremes",
|
||||
},
|
||||
{
|
||||
name: "chain with plaintext inner hop",
|
||||
header: "https, http",
|
||||
want: true,
|
||||
why: "a chained proxy appends its hop; the leftmost " +
|
||||
"element is the client-facing one",
|
||||
},
|
||||
{
|
||||
name: "chain of two TLS hops",
|
||||
header: "https,https",
|
||||
want: true,
|
||||
why: "appended chain with no space after the comma",
|
||||
},
|
||||
{
|
||||
name: "trailing space",
|
||||
header: "https ",
|
||||
want: true,
|
||||
why: "surrounding whitespace is not part of the token",
|
||||
},
|
||||
{
|
||||
name: "leading space",
|
||||
header: " https",
|
||||
want: true,
|
||||
why: "surrounding whitespace is not part of the token",
|
||||
},
|
||||
{
|
||||
name: "uppercase chain",
|
||||
header: "HTTPS, HTTP",
|
||||
want: true,
|
||||
why: "case folding and chain splitting must compose",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// protoPlaintextCases are the values that must NOT be read as TLS.
|
||||
func protoPlaintextCases() []protoCase {
|
||||
return []protoCase{
|
||||
{
|
||||
name: "plaintext",
|
||||
header: "http",
|
||||
want: false,
|
||||
why: "the negative control: the proxy reports plaintext",
|
||||
},
|
||||
{
|
||||
name: "plaintext chain with TLS inner hop",
|
||||
header: "http, https",
|
||||
want: false,
|
||||
why: "the client-facing hop is plaintext even though " +
|
||||
"an inner hop used TLS",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
header: "",
|
||||
want: false,
|
||||
why: "an empty header asserts nothing",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
header: " ",
|
||||
want: false,
|
||||
why: "a blank header asserts nothing",
|
||||
},
|
||||
{
|
||||
name: "unrelated token",
|
||||
header: "ftp",
|
||||
want: false,
|
||||
why: "only https means TLS",
|
||||
},
|
||||
{
|
||||
name: "https as a substring",
|
||||
header: "nothttps",
|
||||
want: false,
|
||||
why: "matching must be on the whole token, not a substring",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTLS_ForwardedProtoSpellings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range protoCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newReq(t)
|
||||
r.Header.Set("X-Forwarded-Proto", tc.header)
|
||||
|
||||
assert.Equal(
|
||||
t, tc.want, reqtls.IsTLS(r),
|
||||
"X-Forwarded-Proto %q: %s", tc.header, tc.why,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTLS_DirectTLSBeatsPlaintextHeader pins the precedence: a
|
||||
// connection this process itself terminated with TLS is a fact, and a
|
||||
// header claiming otherwise does not override it.
|
||||
func TestIsTLS_DirectTLSBeatsPlaintextHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newReq(t)
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
r.Header.Set("X-Forwarded-Proto", "http")
|
||||
|
||||
assert.True(
|
||||
t, reqtls.IsTLS(r),
|
||||
"an actual TLS connection outranks a header claiming plaintext",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIsTLS_FirstHeaderValueWins covers a proxy that adds a second
|
||||
// header line rather than appending to the existing one. net/http
|
||||
// keeps them as separate values; the first is the client-facing hop,
|
||||
// matching how the comma-separated form is read.
|
||||
func TestIsTLS_FirstHeaderValueWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newReq(t)
|
||||
r.Header.Add("X-Forwarded-Proto", "https")
|
||||
r.Header.Add("X-Forwarded-Proto", "http")
|
||||
|
||||
assert.True(
|
||||
t, reqtls.IsTLS(r),
|
||||
"the first header line is the client-facing hop",
|
||||
)
|
||||
}
|
||||
@@ -237,6 +237,10 @@ func (s *Server) setupSourceRoutes() {
|
||||
"/entrypoints/{entrypointID}/toggle",
|
||||
s.h.HandleEntrypointToggle(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/secret",
|
||||
s.h.HandleEntrypointSecret(),
|
||||
)
|
||||
r.Post("/targets", s.h.HandleTargetCreate())
|
||||
// The edit form is the one page that renders a target's
|
||||
// destination URL and header values in full; see
|
||||
|
||||
@@ -147,13 +147,10 @@ func sentryRoutePattern(hint *sentry.EventHint) string {
|
||||
//
|
||||
// The scheme is load-bearing and is kept: the SDK derives it from
|
||||
// r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
// (interfaces.go:180), which is the reason dropping X-Forwarded-Proto
|
||||
// from the header allowlist costs nothing. That predicate is the SDK's
|
||||
// own and is stricter than reqtls.IsTLS, which this service now uses
|
||||
// everywhere it decides transport: the SDK reports "http" for the
|
||||
// "HTTPS" and "https, http" spellings reqtls accepts. Only a reported
|
||||
// scheme is affected, no decision is, so it is left to the SDK rather
|
||||
// than reimplemented. The host is parsed.Host of the SDK's
|
||||
// (interfaces.go:180), byte for byte the predicate
|
||||
// internal/middleware/csrf.go uses, so it is the CSRF TLS decision and
|
||||
// the reason dropping X-Forwarded-Proto from the header allowlist
|
||||
// costs nothing. The host is parsed.Host of the SDK's
|
||||
// scheme://r.Host/path, so it is whatever the client's Host header
|
||||
// carried: this service validates no hostname. It is kept because that
|
||||
// same header is on the allowlist, so scrubbing it here would withhold
|
||||
|
||||
@@ -5,6 +5,6 @@ import "github.com/gorilla/sessions"
|
||||
// NewStore exposes the production cookie-store constructor so tests
|
||||
// exercise the store the application actually runs with, rather than a
|
||||
// lookalike assembled in the test.
|
||||
func NewStore(key []byte) *sessions.CookieStore {
|
||||
return newStore(key)
|
||||
func NewStore(key []byte, secure bool) *sessions.CookieStore {
|
||||
return newStore(key, secure)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/reqtls"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -85,9 +84,10 @@ type Params struct {
|
||||
|
||||
// Session manages encrypted session storage.
|
||||
type Session struct {
|
||||
store *sessions.CookieStore
|
||||
key []byte // raw 32-byte auth key, also used for CSRF cookie signing
|
||||
log *slog.Logger
|
||||
store *sessions.CookieStore
|
||||
key []byte // raw 32-byte auth key, also used for CSRF cookie signing
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
|
||||
// idleTimeout is the sliding inactivity window. A session that
|
||||
// sees no authenticated request within this window expires,
|
||||
@@ -104,10 +104,6 @@ type Session struct {
|
||||
// cookie. MaxAge is deliberately left at its zero value: for a store
|
||||
// it is set through CookieStore.MaxAge (see newStore), and for a
|
||||
// single session it is copied from the store's options.
|
||||
//
|
||||
// Secure is a parameter rather than a constant because it is the one
|
||||
// attribute here that is not a policy -- it is a fact about the
|
||||
// connection carrying this particular response. See applyTransport.
|
||||
func cookieOptions(secure bool) *sessions.Options {
|
||||
return &sessions.Options{
|
||||
Path: "/",
|
||||
@@ -125,52 +121,14 @@ func cookieOptions(secure bool) *sessions.Options {
|
||||
// Options never touches Codecs -- so a store configured that way still
|
||||
// decodes a 30-day-old cookie, leaving the cookie attribute and the
|
||||
// codec disagreeing about the same policy. store.MaxAge sets both.
|
||||
//
|
||||
// The store's Secure is fixed at true, and is only a template: every
|
||||
// write path overwrites it for the request in hand (applyTransport).
|
||||
// It is true rather than false so that a write path added later which
|
||||
// forgets to call applyTransport fails loudly -- the browser drops the
|
||||
// cookie over plaintext HTTP and the developer sees it immediately --
|
||||
// instead of silently shipping the authentication credential without
|
||||
// Secure, which is the exact failure this store already had once.
|
||||
func newStore(key []byte) *sessions.CookieStore {
|
||||
func newStore(key []byte, secure bool) *sessions.CookieStore {
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = cookieOptions(true)
|
||||
store.Options = cookieOptions(secure)
|
||||
store.MaxAge(secondsPerDay * sessionMaxAgeDays)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// applyTransport sets the session cookie's Secure attribute from the
|
||||
// transport of the request being answered.
|
||||
//
|
||||
// This is decided per-request, not once at startup. Deciding it at
|
||||
// startup from the configured environment is what this replaces, and
|
||||
// it got the DEFAULT posture wrong: "dev" is the environment when
|
||||
// WEBHOOKER_ENVIRONMENT is unset, so a deployment terminating TLS at a
|
||||
// proxy without also setting the environment emitted the
|
||||
// authentication cookie with no Secure attribute -- silently, and on
|
||||
// the same response as a CSRF cookie that did have one.
|
||||
//
|
||||
// gorilla/sessions makes this cheap and local: CookieStore.New gives
|
||||
// every session its own copy of the store's Options, and
|
||||
// CookieStore.Save renders the cookie from that copy rather than from
|
||||
// the store. So the flag is set on the one session being saved,
|
||||
// without a second store and without reaching across concurrent
|
||||
// requests.
|
||||
//
|
||||
// The flag tracks the transport in BOTH directions rather than being
|
||||
// latched on once seen. Secure on a plaintext response is worse than
|
||||
// useless: the browser discards such a cookie without any error, so a
|
||||
// latched flag would make a plain-HTTP local run impossible to log
|
||||
// into. It is also why every write path must call this, including the
|
||||
// deletion cookies in Destroy and Regenerate -- a Secure deletion
|
||||
// cookie sent over plaintext is dropped too, leaving the session the
|
||||
// caller believed it had just revoked.
|
||||
func applyTransport(r *http.Request, sess *sessions.Session) {
|
||||
sess.Options.Secure = reqtls.IsTLS(r)
|
||||
}
|
||||
|
||||
// New creates a new session manager. The cookie store is
|
||||
// initialized during the fx OnStart phase after the database is
|
||||
// connected, using a session key that is auto-generated and stored
|
||||
@@ -181,6 +139,7 @@ func New(
|
||||
) (*Session, error) {
|
||||
s := &Session{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
idleTimeout: params.Config.SessionIdleTimeout,
|
||||
now: time.Now,
|
||||
}
|
||||
@@ -213,7 +172,7 @@ func New(
|
||||
}
|
||||
|
||||
s.key = keyBytes
|
||||
s.store = newStore(keyBytes)
|
||||
s.store = newStore(keyBytes, !params.Config.IsDev())
|
||||
s.log.Info("session manager initialized")
|
||||
|
||||
return nil
|
||||
@@ -237,16 +196,12 @@ func (s *Session) GetKey() []byte {
|
||||
return s.key
|
||||
}
|
||||
|
||||
// Save saves the session. Every session-cookie write in the
|
||||
// application goes through here or through Regenerate, which is what
|
||||
// makes applyTransport a complete answer rather than a best effort.
|
||||
// Save saves the session.
|
||||
func (s *Session) Save(
|
||||
r *http.Request,
|
||||
w http.ResponseWriter,
|
||||
sess *sessions.Session,
|
||||
) error {
|
||||
applyTransport(r, sess)
|
||||
|
||||
return sess.Save(r, w)
|
||||
}
|
||||
|
||||
@@ -385,7 +340,6 @@ func (s *Session) Regenerate(
|
||||
// Destroy the old session
|
||||
oldSess.Options.MaxAge = -1
|
||||
s.ClearUser(oldSess)
|
||||
applyTransport(r, oldSess)
|
||||
|
||||
err := oldSess.Save(r, w)
|
||||
if err != nil {
|
||||
@@ -414,7 +368,7 @@ func (s *Session) Regenerate(
|
||||
// Apply the standard session options (the destroyed old
|
||||
// session had MaxAge = -1, which store.New might inherit
|
||||
// from the cookie).
|
||||
newSess.Options = cookieOptions(reqtls.IsTLS(r))
|
||||
newSess.Options = cookieOptions(!s.config.IsDev())
|
||||
newSess.Options.MaxAge = secondsPerDay * sessionMaxAgeDays
|
||||
|
||||
return newSess, nil
|
||||
|
||||
@@ -2,7 +2,6 @@ package session_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -74,7 +73,7 @@ func testSessionWithClock(
|
||||
t.Helper()
|
||||
|
||||
key := testKey()
|
||||
store := session.NewStore(key)
|
||||
store := session.NewStore(key, false)
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
@@ -881,264 +880,3 @@ func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
||||
"destroyed session cookie should have negative MaxAge",
|
||||
)
|
||||
}
|
||||
|
||||
// --- Secure Attribute / Transport Tests ---
|
||||
|
||||
// transportCase describes one client-facing transport and the Secure
|
||||
// attribute the session cookie must carry for it.
|
||||
type transportCase struct {
|
||||
name string
|
||||
tls bool
|
||||
header string
|
||||
want bool
|
||||
why string
|
||||
}
|
||||
|
||||
// transportCases enumerates the transports the session cookie has to
|
||||
// get right. Every https spelling here is one a real proxy emits.
|
||||
func transportCases() []transportCase {
|
||||
return []transportCase{
|
||||
{
|
||||
name: "direct TLS",
|
||||
tls: true,
|
||||
want: true,
|
||||
why: "this process terminated TLS itself",
|
||||
},
|
||||
{
|
||||
name: "proxy reports https",
|
||||
header: "https",
|
||||
want: true,
|
||||
why: "the ordinary reverse-proxy deployment",
|
||||
},
|
||||
{
|
||||
name: "proxy reports HTTPS",
|
||||
header: "HTTPS",
|
||||
want: true,
|
||||
why: "the header value is a case-insensitive token",
|
||||
},
|
||||
{
|
||||
name: "appended chain https, http",
|
||||
header: "https, http",
|
||||
want: true,
|
||||
why: "the leftmost hop is the browser's connection",
|
||||
},
|
||||
{
|
||||
name: "appended chain https,https",
|
||||
header: "https,https",
|
||||
want: true,
|
||||
why: "two TLS hops, no space after the comma",
|
||||
},
|
||||
{
|
||||
name: "trailing space",
|
||||
header: "https ",
|
||||
want: true,
|
||||
why: "whitespace is not part of the token",
|
||||
},
|
||||
{
|
||||
name: "proxy reports http",
|
||||
header: "http",
|
||||
want: false,
|
||||
why: "the negative control: Secure over plaintext is " +
|
||||
"dropped by the browser without a word",
|
||||
},
|
||||
{
|
||||
name: "plaintext, no proxy",
|
||||
want: false,
|
||||
why: "a plain local run must stay loggable-in",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// transportRequest builds a request carrying the case's transport.
|
||||
func (tc transportCase) request(t *testing.T) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet,
|
||||
"http://example.com/", nil,
|
||||
)
|
||||
|
||||
if tc.tls {
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
|
||||
if tc.header != "" {
|
||||
r.Header.Set("X-Forwarded-Proto", tc.header)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// sessionCookieFrom returns the session cookie from a response, or
|
||||
// fails the test if there is none.
|
||||
func sessionCookieFrom(
|
||||
t *testing.T,
|
||||
w *httptest.ResponseRecorder,
|
||||
) *http.Cookie {
|
||||
t.Helper()
|
||||
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == session.SessionName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
require.FailNow(t, "no session cookie in response")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestSave_SecureFollowsRequestTransport is the regression test for
|
||||
// the defect this replaces: Secure was fixed at startup from the
|
||||
// configured environment, and "dev" is the environment when
|
||||
// WEBHOOKER_ENVIRONMENT is unset. A deployment behind a TLS proxy in
|
||||
// that DEFAULT posture shipped the authentication cookie with no
|
||||
// Secure attribute and said nothing about it.
|
||||
//
|
||||
// testSession builds its config with EnvironmentDev precisely so that
|
||||
// the https cases below fail against the old startup-fixed behaviour.
|
||||
func TestSave_SecureFollowsRequestTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range transportCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
r := tc.request(t)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
sess, err := s.Get(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(sess, "user-1", "alice")
|
||||
require.NoError(t, s.Save(r, w, sess))
|
||||
|
||||
assert.Equal(
|
||||
t, tc.want, sessionCookieFrom(t, w).Secure,
|
||||
"session cookie Secure for %q: %s",
|
||||
tc.name, tc.why,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_SecureTracksTransportBothWays pins that the flag is not
|
||||
// latched. One store serves every request, so a Secure cookie set for
|
||||
// a proxied request must not leak into a later plaintext response --
|
||||
// the browser would silently discard that one, and a local run would
|
||||
// become impossible to log into.
|
||||
func TestSave_SecureTracksTransportBothWays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
secureReq := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet,
|
||||
"http://example.com/", nil,
|
||||
)
|
||||
secureReq.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
secureW := httptest.NewRecorder()
|
||||
|
||||
secureSess, err := s.Get(secureReq)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Save(secureReq, secureW, secureSess))
|
||||
require.True(
|
||||
t, sessionCookieFrom(t, secureW).Secure,
|
||||
"proxied request should produce a Secure cookie",
|
||||
)
|
||||
|
||||
plainReq := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet,
|
||||
"http://example.com/", nil,
|
||||
)
|
||||
plainW := httptest.NewRecorder()
|
||||
|
||||
plainSess, err := s.Get(plainReq)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Save(plainReq, plainW, plainSess))
|
||||
|
||||
assert.False(
|
||||
t, sessionCookieFrom(t, plainW).Secure,
|
||||
"a later plaintext request must not inherit Secure from "+
|
||||
"the earlier proxied one",
|
||||
)
|
||||
}
|
||||
|
||||
// TestDestroy_DeletionCookieFollowsTransport covers the trap in the
|
||||
// deletion path. The store's template Secure is true, so a logout over
|
||||
// plaintext that failed to track the transport would emit a Secure
|
||||
// deletion cookie -- which the browser drops, leaving the session the
|
||||
// user just tried to end still sitting in the jar.
|
||||
func TestDestroy_DeletionCookieFollowsTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet,
|
||||
"http://example.com/", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
sess, err := s.Get(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.Destroy(sess)
|
||||
require.NoError(t, s.Save(r, w, sess))
|
||||
|
||||
cookie := sessionCookieFrom(t, w)
|
||||
|
||||
require.Negative(
|
||||
t, cookie.MaxAge,
|
||||
"Destroy then Save should emit a deletion cookie",
|
||||
)
|
||||
assert.False(
|
||||
t, cookie.Secure,
|
||||
"a deletion cookie sent over plaintext must not be Secure, "+
|
||||
"or the browser discards it and the session survives",
|
||||
)
|
||||
}
|
||||
|
||||
// TestRegenerate_BothCookiesFollowTransport covers the login path.
|
||||
// Regenerate writes two cookies -- a deletion for the pre-login
|
||||
// session and the new authenticated one -- and both have to match the
|
||||
// transport or one of them is silently dropped.
|
||||
func TestRegenerate_BothCookiesFollowTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range transportCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
r := tc.request(t)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
oldSess, err := s.Get(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
newSess, err := s.Regenerate(r, w, oldSess)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(newSess, "user-1", "alice")
|
||||
require.NoError(t, s.Save(r, w, newSess))
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
require.Len(
|
||||
t, cookies, 2,
|
||||
"Regenerate then Save writes a deletion cookie "+
|
||||
"and a replacement",
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
assert.Equal(
|
||||
t, tc.want, c.Secure,
|
||||
"cookie %d Secure for %q: %s",
|
||||
c.MaxAge, tc.name, tc.why,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func NewForTest(
|
||||
return &Session{
|
||||
store: store,
|
||||
key: key,
|
||||
config: cfg,
|
||||
log: log,
|
||||
idleTimeout: cfg.SessionIdleTimeout,
|
||||
now: now,
|
||||
|
||||
283
internal/signature/signature.go
Normal file
283
internal/signature/signature.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// Package signature verifies that an inbound webhook request really
|
||||
// came from the sender an entrypoint was configured for.
|
||||
//
|
||||
// Verification is optional and per entrypoint. An entrypoint with no
|
||||
// scheme configured is not verified at all, which is what every
|
||||
// entrypoint was before this package existed. An entrypoint whose
|
||||
// configuration is present but incoherent is failed closed, never
|
||||
// treated as unverified: the whole point of the feature is that
|
||||
// turning it on cannot silently turn itself back off.
|
||||
package signature
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// Header names each supported scheme reads its signature from.
|
||||
const (
|
||||
// HeaderGitHub is GitHub's HMAC-SHA256 signature header. GitHub
|
||||
// also sends the older SHA-1 X-Hub-Signature; it is not accepted.
|
||||
HeaderGitHub = "X-Hub-Signature-256"
|
||||
|
||||
// HeaderGitLab is GitLab's plain shared-token header.
|
||||
HeaderGitLab = "X-Gitlab-Token"
|
||||
)
|
||||
|
||||
// githubPrefix is the algorithm label GitHub puts in front of the hex
|
||||
// digest. It is required, not optional: accepting a bare digest too
|
||||
// would mean accepting a spelling no supported sender produces.
|
||||
const githubPrefix = "sha256="
|
||||
|
||||
// ErrConfig marks a failure caused by the entrypoint's stored
|
||||
// configuration rather than by the request. A caller must fail these
|
||||
// closed — refuse the request — because the alternative is an
|
||||
// entrypoint the operator believes is verified silently accepting
|
||||
// anything.
|
||||
var ErrConfig = errors.New("entrypoint signature configuration invalid")
|
||||
|
||||
// ErrUnauthorized marks a request that failed verification. A caller
|
||||
// answers these 401.
|
||||
var ErrUnauthorized = errors.New("inbound signature verification failed")
|
||||
|
||||
// Configuration failures. None of these carry any part of the secret.
|
||||
var (
|
||||
errSchemeUnknown = fmt.Errorf(
|
||||
"%w: unsupported scheme", ErrConfig,
|
||||
)
|
||||
errSecretMissing = fmt.Errorf(
|
||||
"%w: scheme set with no secret", ErrConfig,
|
||||
)
|
||||
errSchemeMissing = fmt.Errorf(
|
||||
"%w: secret set with no scheme", ErrConfig,
|
||||
)
|
||||
)
|
||||
|
||||
// Request failures. These are logged, so none of them carries the
|
||||
// value the client sent: under the GitLab scheme that value is a
|
||||
// guess at the token, and under either scheme a misconfigured sender
|
||||
// could be presenting the real one.
|
||||
var (
|
||||
errHeaderMissing = fmt.Errorf(
|
||||
"%w: signature header absent", ErrUnauthorized,
|
||||
)
|
||||
errHeaderMalformed = fmt.Errorf(
|
||||
"%w: signature header malformed", ErrUnauthorized,
|
||||
)
|
||||
errSignatureMismatch = fmt.Errorf(
|
||||
"%w: signature does not match", ErrUnauthorized,
|
||||
)
|
||||
)
|
||||
|
||||
// SchemeInfo describes one supported scheme for the UI.
|
||||
type SchemeInfo struct {
|
||||
Scheme database.SignatureScheme
|
||||
Label string
|
||||
Header string
|
||||
|
||||
// HeaderIsDigest reports that Header carries a value derived from
|
||||
// the request rather than the shared secret itself, and so may be
|
||||
// kept when the request is stored and forwarded.
|
||||
//
|
||||
// The polarity is deliberate: false — the zero value — means the
|
||||
// header is the credential and must be stripped. A scheme added
|
||||
// later is therefore stripped unless whoever adds it positively
|
||||
// declares the header safe to keep.
|
||||
HeaderIsDigest bool
|
||||
}
|
||||
|
||||
// Schemes returns the supported schemes in the order the UI offers
|
||||
// them. It returns a fresh slice per call so no caller can edit the
|
||||
// set out from under another.
|
||||
func Schemes() []SchemeInfo {
|
||||
return []SchemeInfo{
|
||||
{
|
||||
Scheme: database.SignatureSchemeGitHub,
|
||||
Label: "GitHub",
|
||||
Header: HeaderGitHub,
|
||||
// An HMAC over the body, not the key. Keeping it lets an
|
||||
// operator see what the sender sent.
|
||||
HeaderIsDigest: true,
|
||||
},
|
||||
{
|
||||
Scheme: database.SignatureSchemeGitLab,
|
||||
Label: "GitLab",
|
||||
Header: HeaderGitLab,
|
||||
// X-Gitlab-Token is the shared secret in plaintext.
|
||||
HeaderIsDigest: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Info returns the description of a supported scheme. It reports
|
||||
// false for the empty scheme and for anything unrecognised, which is
|
||||
// what a row hand-edited in the database could hold.
|
||||
func Info(scheme database.SignatureScheme) (SchemeInfo, bool) {
|
||||
for _, s := range Schemes() {
|
||||
if s.Scheme == scheme {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
return SchemeInfo{}, false
|
||||
}
|
||||
|
||||
// Supported reports whether a scheme may be stored on an entrypoint.
|
||||
// The empty scheme is supported: it means no verification.
|
||||
func Supported(scheme database.SignatureScheme) bool {
|
||||
if scheme == database.SignatureSchemeNone {
|
||||
return true
|
||||
}
|
||||
|
||||
_, ok := Info(scheme)
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// SanitizeHeaders returns a copy of an accepted request's headers
|
||||
// with the entrypoint's credential removed.
|
||||
//
|
||||
// Under a scheme whose header is the shared secret itself — GitLab's
|
||||
// X-Gitlab-Token — every downstream use of the inbound headers is a
|
||||
// disclosure of the credential: they are persisted verbatim in the
|
||||
// per-webhook event store and forwarded to every delivery target, so
|
||||
// a target operator or anyone who reads the event database could
|
||||
// forge signed requests to the very entrypoint the secret protects.
|
||||
// Stripping happens here, once, above the first write, rather than
|
||||
// at each egress, so a new consumer of Event.Headers cannot reopen
|
||||
// the leak by forgetting to filter.
|
||||
//
|
||||
// header is never modified; the caller's request keeps its headers
|
||||
// intact for anything that still needs the original.
|
||||
//
|
||||
// An entrypoint with no scheme, or one whose stored scheme this
|
||||
// build does not know, is returned unchanged: there is no configured
|
||||
// credential to remove, and the unknown case is refused by Verify
|
||||
// before a request reaches storage.
|
||||
func SanitizeHeaders(
|
||||
entrypoint *database.Entrypoint,
|
||||
header http.Header,
|
||||
) http.Header {
|
||||
clone := header.Clone()
|
||||
if clone == nil {
|
||||
return header
|
||||
}
|
||||
|
||||
info, ok := Info(entrypoint.SignatureScheme)
|
||||
if !ok || info.HeaderIsDigest {
|
||||
return clone
|
||||
}
|
||||
|
||||
clone.Del(info.Header)
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// Verify checks an inbound request against an entrypoint's
|
||||
// configuration and returns nil when the request may be accepted.
|
||||
//
|
||||
// body must be the raw bytes exactly as received, before any parsing
|
||||
// or normalisation: the sender computed its digest over those bytes,
|
||||
// so anything that re-encodes them produces a different digest and a
|
||||
// spurious rejection. The caller is also responsible for bounding
|
||||
// that read; this package hashes what it is handed.
|
||||
//
|
||||
// Every non-nil error is either ErrConfig or ErrUnauthorized, so a
|
||||
// caller can tell "the server is misconfigured" from "the client did
|
||||
// not authenticate" with errors.Is.
|
||||
func Verify(
|
||||
entrypoint *database.Entrypoint,
|
||||
header http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
scheme := entrypoint.SignatureScheme
|
||||
secret := entrypoint.SignatureSecret
|
||||
|
||||
if scheme == database.SignatureSchemeNone {
|
||||
// A secret with no scheme names no header and no algorithm,
|
||||
// so there is nothing to check it with. Accepting the request
|
||||
// would make a half-applied configuration indistinguishable
|
||||
// from no configuration at all.
|
||||
if secret != "" {
|
||||
return errSchemeMissing
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if secret == "" {
|
||||
return errSecretMissing
|
||||
}
|
||||
|
||||
switch scheme {
|
||||
case database.SignatureSchemeGitHub:
|
||||
return verifyGitHub(secret, header.Get(HeaderGitHub), body)
|
||||
case database.SignatureSchemeGitLab:
|
||||
return verifyGitLab(secret, header.Get(HeaderGitLab))
|
||||
case database.SignatureSchemeNone:
|
||||
// Handled above; restated so the switch stays exhaustive and
|
||||
// adding a scheme has to be decided here.
|
||||
return nil
|
||||
default:
|
||||
return errSchemeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// verifyGitHub checks a GitHub-style X-Hub-Signature-256: the string
|
||||
// "sha256=" followed by the hex HMAC-SHA256 of the raw body under the
|
||||
// shared secret.
|
||||
func verifyGitHub(secret, provided string, body []byte) error {
|
||||
if provided == "" {
|
||||
return errHeaderMissing
|
||||
}
|
||||
|
||||
encoded, ok := strings.CutPrefix(provided, githubPrefix)
|
||||
if !ok {
|
||||
return errHeaderMalformed
|
||||
}
|
||||
|
||||
got, err := hex.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return errHeaderMalformed
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
|
||||
// hash.Hash.Write is documented never to return an error.
|
||||
_, _ = mac.Write(body)
|
||||
|
||||
// hmac.Equal, never ==: string comparison stops at the first
|
||||
// differing byte, which tells a client how much of a forged
|
||||
// digest it got right and turns forgery into a per-byte search.
|
||||
if !hmac.Equal(mac.Sum(nil), got) {
|
||||
return errSignatureMismatch
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyGitLab checks a GitLab-style X-Gitlab-Token, which is the
|
||||
// shared secret itself rather than a digest over the body.
|
||||
//
|
||||
// The comparison is constant time in the same way as the HMAC one.
|
||||
// hmac.Equal returns early for unequal lengths, so the length of the
|
||||
// token is not hidden; its contents are, and length alone does not
|
||||
// let a client search for the value.
|
||||
func verifyGitLab(secret, provided string) error {
|
||||
if provided == "" {
|
||||
return errHeaderMissing
|
||||
}
|
||||
|
||||
if !hmac.Equal([]byte(provided), []byte(secret)) {
|
||||
return errSignatureMismatch
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
340
internal/signature/signature_test.go
Normal file
340
internal/signature/signature_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package signature_test
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
)
|
||||
|
||||
const (
|
||||
// testSharedKey is the shared secret under test. It is not named
|
||||
// "secret": gosec reads a credential-shaped name bound to a
|
||||
// high-entropy literal as a leaked credential, which is the right
|
||||
// rule and the wrong finding here.
|
||||
testSharedKey = "s3kr1t-shared-value"
|
||||
testBody = `{"action":"opened","number":1}`
|
||||
)
|
||||
|
||||
// githubSignature returns the X-Hub-Signature-256 value GitHub would
|
||||
// send for testBody signed with secret.
|
||||
func githubSignature(secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(testBody))
|
||||
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// headerWith builds a request header carrying one value.
|
||||
func headerWith(name, value string) http.Header {
|
||||
h := http.Header{}
|
||||
if name != "" {
|
||||
h.Set(name, value)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// entrypoint builds an entrypoint with a signature configuration.
|
||||
func entrypoint(
|
||||
scheme database.SignatureScheme, secret string,
|
||||
) *database.Entrypoint {
|
||||
return &database.Entrypoint{
|
||||
SignatureScheme: scheme,
|
||||
SignatureSecret: secret,
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyUnconfiguredAcceptsAnything pins the pass-through case:
|
||||
// an entrypoint with no scheme is the entrypoint every deployment
|
||||
// already has, and it must keep accepting requests that carry no
|
||||
// signature at all.
|
||||
func TestVerifyUnconfiguredAcceptsAnything(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ep := entrypoint(database.SignatureSchemeNone, "")
|
||||
|
||||
require.NoError(
|
||||
t, signature.Verify(ep, http.Header{}, []byte(testBody)),
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
signature.Verify(
|
||||
ep,
|
||||
headerWith(signature.HeaderGitHub, "sha256=deadbeef"),
|
||||
[]byte(testBody),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// githubCase is one inbound request against a GitHub-scheme
|
||||
// entrypoint.
|
||||
type githubCase struct {
|
||||
name string
|
||||
header string
|
||||
value string
|
||||
body string
|
||||
want error
|
||||
}
|
||||
|
||||
// githubCases enumerates the shapes a GitHub signature can arrive in.
|
||||
func githubCases() []githubCase {
|
||||
valid := githubSignature(testSharedKey)
|
||||
|
||||
return []githubCase{
|
||||
{
|
||||
name: "valid",
|
||||
header: signature.HeaderGitHub,
|
||||
value: valid,
|
||||
body: testBody,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "absent header",
|
||||
header: "",
|
||||
body: testBody,
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "wrong secret",
|
||||
header: signature.HeaderGitHub,
|
||||
value: githubSignature("not-the-shared-value"),
|
||||
body: testBody,
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
// The digest is valid for a different body: the check
|
||||
// has to be over the bytes actually received.
|
||||
name: "body altered in flight",
|
||||
header: signature.HeaderGitHub,
|
||||
value: valid,
|
||||
body: testBody + " ",
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "missing algorithm prefix",
|
||||
header: signature.HeaderGitHub,
|
||||
value: valid[len("sha256="):],
|
||||
body: testBody,
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "not hex",
|
||||
header: signature.HeaderGitHub,
|
||||
value: "sha256=zzzz",
|
||||
body: testBody,
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "empty digest",
|
||||
header: signature.HeaderGitHub,
|
||||
value: "sha256=",
|
||||
body: testBody,
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
// GitLab's header does not authenticate a GitHub
|
||||
// entrypoint, even holding the right secret.
|
||||
name: "wrong header for the scheme",
|
||||
header: signature.HeaderGitLab,
|
||||
value: testSharedKey,
|
||||
body: testBody,
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyGitHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range githubCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := signature.Verify(
|
||||
entrypoint(
|
||||
database.SignatureSchemeGitHub, testSharedKey,
|
||||
),
|
||||
headerWith(tc.header, tc.value),
|
||||
[]byte(tc.body),
|
||||
)
|
||||
|
||||
if tc.want == nil {
|
||||
require.NoError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.ErrorIs(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyGitLab(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
value string
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
header: signature.HeaderGitLab,
|
||||
value: testSharedKey,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "absent header",
|
||||
header: "",
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "wrong token",
|
||||
header: signature.HeaderGitLab,
|
||||
value: "not-the-shared-value",
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "token prefix only",
|
||||
header: signature.HeaderGitLab,
|
||||
value: testSharedKey[:5],
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "wrong header for the scheme",
|
||||
header: signature.HeaderGitHub,
|
||||
value: githubSignature(testSharedKey),
|
||||
want: signature.ErrUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := signature.Verify(
|
||||
entrypoint(
|
||||
database.SignatureSchemeGitLab, testSharedKey,
|
||||
),
|
||||
headerWith(tc.header, tc.value),
|
||||
[]byte(testBody),
|
||||
)
|
||||
|
||||
if tc.want == nil {
|
||||
require.NoError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.ErrorIs(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyBrokenConfigurationFailsClosed covers the rows a caller
|
||||
// must refuse rather than wave through. Each is a state an operator
|
||||
// could only reach outside the UI, and each one would otherwise be
|
||||
// indistinguishable from "verification is off".
|
||||
func TestVerifyBrokenConfigurationFailsClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
scheme database.SignatureScheme
|
||||
secret string
|
||||
}{
|
||||
{
|
||||
name: "unknown scheme",
|
||||
scheme: database.SignatureScheme("stripe"),
|
||||
secret: testSharedKey,
|
||||
},
|
||||
{
|
||||
name: "scheme without secret",
|
||||
scheme: database.SignatureSchemeGitHub,
|
||||
secret: "",
|
||||
},
|
||||
{
|
||||
name: "secret without scheme",
|
||||
scheme: database.SignatureSchemeNone,
|
||||
secret: testSharedKey,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := signature.Verify(
|
||||
entrypoint(tc.scheme, tc.secret),
|
||||
headerWith(
|
||||
signature.HeaderGitHub,
|
||||
githubSignature(testSharedKey),
|
||||
),
|
||||
[]byte(testBody),
|
||||
)
|
||||
|
||||
require.ErrorIs(t, err, signature.ErrConfig)
|
||||
assert.NotErrorIs(t, err, signature.ErrUnauthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorsCarryNoSecret proves the strings that reach the log hold
|
||||
// no part of the shared secret or of what the client presented.
|
||||
func TestErrorsCarryNoSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const presented = "QQPRESENTEDTOKENQQ"
|
||||
|
||||
for _, scheme := range []database.SignatureScheme{
|
||||
database.SignatureSchemeGitHub,
|
||||
database.SignatureSchemeGitLab,
|
||||
} {
|
||||
for _, header := range []string{
|
||||
signature.HeaderGitHub, signature.HeaderGitLab,
|
||||
} {
|
||||
err := signature.Verify(
|
||||
entrypoint(scheme, testSharedKey),
|
||||
headerWith(header, presented),
|
||||
[]byte(testBody),
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), testSharedKey)
|
||||
assert.NotContains(t, err.Error(), presented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemeMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, signature.Supported(database.SignatureSchemeNone))
|
||||
assert.True(t, signature.Supported(database.SignatureSchemeGitHub))
|
||||
assert.True(t, signature.Supported(database.SignatureSchemeGitLab))
|
||||
assert.False(
|
||||
t, signature.Supported(database.SignatureScheme("stripe")),
|
||||
)
|
||||
|
||||
// The empty scheme describes no sender, so it has no info even
|
||||
// though it is a storable value.
|
||||
_, ok := signature.Info(database.SignatureSchemeNone)
|
||||
assert.False(t, ok)
|
||||
|
||||
info, ok := signature.Info(database.SignatureSchemeGitHub)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "GitHub", info.Label)
|
||||
assert.Equal(t, signature.HeaderGitHub, info.Header)
|
||||
|
||||
info, ok = signature.Info(database.SignatureSchemeGitLab)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "GitLab", info.Label)
|
||||
assert.Equal(t, signature.HeaderGitLab, info.Header)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Package versionscript holds the tests for script/version and for the
|
||||
// build files that consume it. It carries no runtime code: the version
|
||||
// string is produced by a shell script at build time and reaches the
|
||||
// binary through a linker flag, so nothing in the Go build graph can
|
||||
// assert it, but the behaviour still has to be verified by the test
|
||||
// suite.
|
||||
//
|
||||
// The files under test are outside the Go build graph, so `go test`'s
|
||||
// result cache serves a stale PASS when only script/version, the
|
||||
// Makefile or the Dockerfile changed: run the container build, or
|
||||
// GOFLAGS=-count=1, to trust a result here after editing them.
|
||||
package versionscript
|
||||
@@ -1,345 +0,0 @@
|
||||
package versionscript_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
repoRoot = "../.."
|
||||
scriptPath = "../../script/version"
|
||||
makefilePath = "../../Makefile"
|
||||
dockerfilePath = "../../Dockerfile"
|
||||
|
||||
// unknown is what a tree with no git metadata and no $VERSION must
|
||||
// report: a source tarball has no way to know its version, and the
|
||||
// one thing it must not do is name a tag it may not be at.
|
||||
unknown = "unknown"
|
||||
|
||||
// scriptMode keeps the copied script runnable; dirMode and fileMode
|
||||
// are the ordinary permissions for the throwaway tree around it.
|
||||
scriptMode = 0o755
|
||||
dirMode = 0o750
|
||||
fileMode = 0o600
|
||||
)
|
||||
|
||||
// checkout is a throwaway working tree carrying a copy of the script
|
||||
// under test at the same path it lives at in this repository, since the
|
||||
// script resolves the checkout root from its own location.
|
||||
type checkout struct {
|
||||
dir string
|
||||
head string
|
||||
}
|
||||
|
||||
func TestVersion_CleanTagReportsExactlyTheTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
require.Equal(t, "v1.2.3", c.version(t))
|
||||
}
|
||||
|
||||
func TestVersion_CommitAfterTagCarriesDistanceAndSHA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
head := c.commit(t, "after the tag")
|
||||
|
||||
got := c.version(t)
|
||||
|
||||
require.NotEqual(t, "v1.2.3", got,
|
||||
"a commit past the tag must not claim to be the tag")
|
||||
require.True(t, strings.HasPrefix(got, "v1.2.3-1-g"),
|
||||
"want describe form v1.2.3-1-g<sha>, got %q", got)
|
||||
require.True(t, strings.HasPrefix(head, strings.TrimPrefix(
|
||||
got, "v1.2.3-1-g")),
|
||||
"%q must carry the abbreviated head SHA of %q", got, head)
|
||||
}
|
||||
|
||||
func TestVersion_UntaggedHistoryReportsShortSHA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
|
||||
got := c.version(t)
|
||||
|
||||
require.NotEmpty(t, got)
|
||||
require.NotEqual(t, unknown, got)
|
||||
require.True(t, strings.HasPrefix(c.head, got),
|
||||
"%q must be an abbreviation of head %q", got, c.head)
|
||||
}
|
||||
|
||||
func TestVersion_UncommittedChangesAreMarkedDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(c.dir, "tracked.txt"), []byte("edited\n"), fileMode,
|
||||
))
|
||||
|
||||
require.Equal(t, "v1.2.3-dirty", c.version(t))
|
||||
}
|
||||
|
||||
// A source tarball, or any build context without .git, still has to
|
||||
// build. It reports "unknown" rather than failing or naming a tag.
|
||||
func TestVersion_NoGitMetadataReportsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
require.NoError(t, os.RemoveAll(filepath.Join(c.dir, ".git")))
|
||||
|
||||
require.Equal(t, unknown, c.version(t))
|
||||
}
|
||||
|
||||
// An unpacked tarball can sit inside an unrelated working copy. The
|
||||
// enclosing repository's version is not this tree's version.
|
||||
func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outer := newCheckout(t)
|
||||
outer.git(t, "tag", "v9.9.9")
|
||||
|
||||
inner := filepath.Join(outer.dir, "unpacked")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(inner, "script"), dirMode))
|
||||
copyScript(t, inner)
|
||||
|
||||
require.Equal(t, unknown, runScript(t, inner, nil))
|
||||
}
|
||||
|
||||
// The Docker build has no git metadata, so the version arrives as an
|
||||
// environment override. It wins over anything derivable.
|
||||
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
require.Equal(t, "v4.5.6",
|
||||
runScript(t, c.dir, []string{"VERSION=v4.5.6"}))
|
||||
}
|
||||
|
||||
// An empty VERSION is treated as unset rather than stamping an empty
|
||||
// string: the Dockerfile's build arg has a non-empty default, but a
|
||||
// caller exporting VERSION= must not produce a binary reporting "".
|
||||
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
require.Equal(t, "v1.2.3", runScript(t, c.dir, []string{"VERSION="}))
|
||||
}
|
||||
|
||||
// Two builds of the same commit must produce a byte-identical binary,
|
||||
// which they cannot if the stamped value moves between invocations.
|
||||
func TestVersion_IsStableAcrossInvocations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
first := c.version(t)
|
||||
second := c.version(t)
|
||||
|
||||
require.Equal(t, first, second)
|
||||
}
|
||||
|
||||
// The build target is the only place composing linker flags. If a
|
||||
// future edit drops either half, the binary silently reports "dev"
|
||||
// again (the defect this package exists for) or fails to link on
|
||||
// Alpine.
|
||||
func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makefile := read(t, makefilePath)
|
||||
|
||||
require.Contains(t, makefile, "-X main.version=$(VERSION)")
|
||||
require.Contains(t, makefile, "$(GO_LDFLAGS)")
|
||||
require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
|
||||
}
|
||||
|
||||
// A caller can define VERSION as the empty string -- `make build
|
||||
// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make
|
||||
// build VERSION="$VERSION"`. script/version's own guard does not cover
|
||||
// that: the value never passes through the script. Stamping "" would
|
||||
// leave the binary reporting no version and the footer on "dev", which
|
||||
// is the defect this package exists for.
|
||||
func TestMakefile_EmptyOverrideResolvesLikeAnUnsetOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A plain assignment would be ignored here: a command-line
|
||||
// definition outranks it, and that is the case being corrected.
|
||||
require.Contains(t, read(t, makefilePath), "override VERSION :=")
|
||||
|
||||
requireMake(t)
|
||||
|
||||
derived := makeVersion(t)
|
||||
require.NotEmpty(t, derived)
|
||||
|
||||
require.Equal(t, derived, makeVersion(t, "VERSION="),
|
||||
"an empty VERSION must resolve the way an unset one does")
|
||||
require.Equal(t, "v9.9.9", makeVersion(t, "VERSION=v9.9.9"),
|
||||
"the empty guard must not clobber a real override")
|
||||
}
|
||||
|
||||
// makeVersion runs this repository's `version` target, which prints the
|
||||
// value `make build` would stamp, with the given command-line
|
||||
// definitions.
|
||||
func makeVersion(t *testing.T, defs ...string) string {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // fixed argv, arguments are test constants
|
||||
cmd := exec.CommandContext(t.Context(), "make",
|
||||
append([]string{"--no-print-directory", "version"}, defs...)...)
|
||||
cmd.Dir = repoRoot
|
||||
|
||||
// Only the command-line definitions may decide the outcome: an
|
||||
// inherited VERSION would change what an unset one resolves to, and
|
||||
// an inherited MAKEFLAGS carries the parent's jobserver.
|
||||
cmd.Env = append(os.Environ(), "VERSION=", "MAKEFLAGS=", "MAKELEVEL=")
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func requireMake(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
_, err := exec.LookPath("make")
|
||||
if err != nil {
|
||||
t.Skipf("make is not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every compile in the image goes through the build target, so the
|
||||
// static relink cannot replace the flags that carry the stamp.
|
||||
func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dockerfile := read(t, dockerfilePath)
|
||||
|
||||
require.NotContains(t, dockerfile, "go build",
|
||||
"a raw go build bypasses the Makefile's -X flag")
|
||||
require.Contains(t, dockerfile, "ARG VERSION=")
|
||||
require.Contains(t, dockerfile,
|
||||
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
||||
}
|
||||
|
||||
func read(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // repo-local build file under test, fixed path
|
||||
b, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// version runs the script in this checkout with no overrides.
|
||||
func (c checkout) version(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return runScript(t, c.dir, nil)
|
||||
}
|
||||
|
||||
func runScript(t *testing.T, dir string, env []string) string {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // fixed argv, repo-local script under test
|
||||
cmd := exec.CommandContext(t.Context(), "sh",
|
||||
filepath.Join(dir, "script", "version"))
|
||||
cmd.Dir = dir
|
||||
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func (c checkout) git(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // fixed argv, arguments are test constants
|
||||
cmd := exec.CommandContext(t.Context(), "git", args...)
|
||||
cmd.Dir = c.dir
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func (c checkout) commit(t *testing.T, message string) string {
|
||||
t.Helper()
|
||||
|
||||
c.git(t,
|
||||
"-c", "user.email=ci@example.invalid",
|
||||
"-c", "user.name=ci",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-q", "--allow-empty", "-m", message,
|
||||
)
|
||||
|
||||
return c.git(t, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// newCheckout builds a one-commit repository with a tracked file, so a
|
||||
// later edit to that file makes the tree dirty, and with a copy of the
|
||||
// script at the path it occupies in this repository.
|
||||
func newCheckout(t *testing.T) checkout {
|
||||
t.Helper()
|
||||
|
||||
requireGit(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
c := checkout{dir: dir}
|
||||
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "script"), dirMode))
|
||||
copyScript(t, dir)
|
||||
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(dir, "tracked.txt"), []byte("original\n"), fileMode,
|
||||
))
|
||||
|
||||
c.git(t, "init", "-q", "-b", "main")
|
||||
c.git(t, "add", "tracked.txt")
|
||||
c.head = c.commit(t, "initial")
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func copyScript(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
body, err := os.ReadFile(scriptPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // the copy has to stay executable to be run
|
||||
err = os.WriteFile(
|
||||
filepath.Join(dir, "script", "version"), body, scriptMode,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func requireGit(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
for _, tool := range []string{"sh", "git"} {
|
||||
_, err := exec.LookPath(tool)
|
||||
if err != nil {
|
||||
t.Skipf("%s is not installed: %v", tool, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# The tag comes from script/projectname.
|
||||
#
|
||||
# .dockerignore excludes .git/, so the builder stage cannot derive the
|
||||
# version itself. It is resolved here, where the checkout is, and passed
|
||||
# in as a build arg; without it the image would stamp itself "unknown".
|
||||
# Identical in all repos; the tag comes from script/projectname.
|
||||
# Generic: needs no adaptation.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -12,9 +9,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build \
|
||||
--build-arg VERSION="$("$SCRIPT_DIR/version")" \
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/version: output the version string the binary is stamped with.
|
||||
# Our own extension to scripts-to-rule-them-all. The Makefile's build
|
||||
# target and script/docker both take the value from here, so a `make
|
||||
# build` binary and a `make docker` image built from the same checkout
|
||||
# report the same thing.
|
||||
#
|
||||
# Order of precedence:
|
||||
#
|
||||
# 1. $VERSION, if set and non-empty. This is how the value reaches a
|
||||
# build that cannot derive it: .dockerignore excludes .git/, so the
|
||||
# builder stage has no git metadata and the Dockerfile takes the
|
||||
# value as a build arg instead.
|
||||
# 2. `git describe --tags --always --dirty` against this checkout. At
|
||||
# a clean tagged commit that is exactly the tag; otherwise it
|
||||
# carries the short SHA, the commit distance when a tag is
|
||||
# reachable, and a -dirty suffix for uncommitted changes.
|
||||
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
||||
# source tarball, or `docker build .` with no --build-arg. That
|
||||
# case must not fail the build and must not name a tag the tree may
|
||||
# not be at, so it names nothing.
|
||||
#
|
||||
# The git step insists the enclosing repository is this checkout, not
|
||||
# merely some repository above it: an unpacked tarball sitting inside an
|
||||
# unrelated working copy would otherwise be stamped with that copy's
|
||||
# version.
|
||||
#
|
||||
# Nothing here may vary between two builds of the same commit: the
|
||||
# release gate asserts the binary is byte-identical across builds. That
|
||||
# rules out a build timestamp, a hostname, and a builder identity.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# in_this_checkout succeeds when git can read metadata for a repository
|
||||
# whose work tree root is $ROOT.
|
||||
in_this_checkout() {
|
||||
command -v git >/dev/null 2>&1 || return 1
|
||||
|
||||
top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
|
||||
[ -n "$top" ] || return 1
|
||||
|
||||
top="$(cd "$top" 2>/dev/null && pwd -P)" || return 1
|
||||
[ "$top" = "$ROOT" ]
|
||||
}
|
||||
|
||||
main() {
|
||||
if [ -n "${VERSION:-}" ]; then
|
||||
echo "$VERSION"
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if in_this_checkout; then
|
||||
# --always keeps an untagged history from failing the build: it
|
||||
# falls back to the bare short SHA.
|
||||
git describe --tags --always --dirty 2>/dev/null && return 0
|
||||
fi
|
||||
|
||||
echo "unknown"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Entrypoints}}
|
||||
<div class="p-4">
|
||||
<div class="p-4" x-data="{ showSecret: false }">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -75,8 +75,38 @@
|
||||
script the URL above stays selectable. -->
|
||||
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
||||
</div>
|
||||
<!-- The URL above is the entrypoint's credential:
|
||||
anyone holding it can submit events. -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs text-gray-500">
|
||||
Signature: {{.SchemeLabel}}{{if .SchemeHeader}} ({{.SchemeHeader}}){{end}}
|
||||
</span>
|
||||
<button type="button" @click="showSecret = !showSecret" class="text-xs text-gray-500 hover:text-primary-600">
|
||||
{{if .Configured}}Rotate{{else}}Configure{{end}}
|
||||
</button>
|
||||
</div>
|
||||
<!-- The stored secret is never sent to the browser: the
|
||||
form takes a new one every time, so setting and
|
||||
rotating are the same submission. -->
|
||||
<div x-show="showSecret" x-cloak class="mt-2">
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/secret" class="flex gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<select name="signature_scheme" class="input text-sm w-28">
|
||||
<!-- Selection follows the stored scheme, not
|
||||
whether the pair is complete: a row with a
|
||||
scheme and no secret would otherwise mark
|
||||
both this option and its own selected. -->
|
||||
<option value="" {{if not .Scheme}}selected{{end}}>None</option>
|
||||
{{$current := .Scheme}}
|
||||
{{range $.SignatureSchemes}}
|
||||
<option value="{{.Scheme}}" {{if eq .Scheme $current}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<input type="password" name="secret" autocomplete="new-password" placeholder="Shared secret" class="input text-sm flex-1">
|
||||
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||
</form>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Enter the same secret you configured at the sender. Selecting None removes verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
||||
|
||||
Reference in New Issue
Block a user