Add optional inbound webhook signature verification (closes #67) (#228)
Some checks failed
check / check (push) Superseded by a newer commit; never tested
Some checks failed
check / check (push) Superseded by a newer commit; never tested
The receiver had no inbound authentication of any kind: /webhook/{uuid}
was mounted behind a rate limiter alone, so the only thing protecting an
entrypoint was the secrecy of a v4 UUID in a URL path. Inbound headers are
forwarded almost verbatim to the target, so anyone who learned the URL
also chose the headers the downstream service received.
Adds an optional per-entrypoint secret with two schemes: github
(X-Hub-Signature-256, HMAC-SHA256 hex over the raw body) and gitlab
(X-Gitlab-Token, a plain shared token). Comparison is constant-time, the
HMAC is computed over the raw body before any parsing, and rejection
happens before persistence -- an unauthenticated request creates no event
row. An entrypoint with no secret behaves exactly as before, including
every row that predates this change.
The scheme's credential header is stripped from the header map before it
is marshalled into Event.Headers, so the GitLab token reaches neither the
event store nor any delivery target. SchemeInfo.HeaderIsDigest defaults to
false meaning strip, so a scheme added later is protected unless its
header is positively declared a digest.
This commit was merged in pull request #228.
This commit is contained in:
144
README.md
144
README.md
@@ -530,7 +530,110 @@ 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.
|
||||
|
||||
## Inbound Signature Verification
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -807,9 +910,15 @@ the full request and creates an Event.
|
||||
| `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
|
||||
@@ -1079,12 +1188,16 @@ External Service
|
||||
└─────────────┘ └──────────────┘ └──────┬───────┘
|
||||
│
|
||||
1. Look up Entrypoint by UUID
|
||||
2. Capture full request as Event
|
||||
3. Create Delivery records for each active Target
|
||||
4. Build self-contained delivery.Task structs
|
||||
2. Read the body under the 1 MB cap
|
||||
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)
|
||||
5. Notify Engine via channel (no DB read needed)
|
||||
7. Notify Engine via channel (no DB read needed)
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
@@ -1845,6 +1958,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 |
|
||||
@@ -1891,7 +2005,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)
|
||||
@@ -1926,6 +2040,7 @@ webhooker/
|
||||
│ ├── handlers/
|
||||
│ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering
|
||||
│ │ ├── auth.go # Login, logout handlers
|
||||
│ │ ├── entrypoint_view.go # Masked entrypoint view for templates
|
||||
│ │ ├── event_log_view.go # Event log projection, byte-capped in SQL
|
||||
│ │ ├── healthcheck.go # Health check handler
|
||||
│ │ ├── index.go # Index page handler
|
||||
@@ -1950,9 +2065,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)
|
||||
@@ -2094,6 +2211,15 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
`/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
|
||||
|
||||
Reference in New Issue
Block a user