Add optional inbound webhook signature verification (closes #67)
Some checks failed
check / check (push) Failing after 2m31s
Some checks failed
check / check (push) Failing after 2m31s
A receiver URL was a bare v4 UUID and nothing else: anyone who learned it could store events and, because inbound headers are forwarded to targets almost verbatim, choose what the downstream service received. Entrypoints gain an optional scheme/secret pair. GitHub's X-Hub-Signature-256 (HMAC-SHA256 hex over the raw body) and GitLab's X-Gitlab-Token (plain shared token) are supported; both compare with hmac.Equal. With nothing configured an entrypoint behaves exactly as before, which is also where every pre-existing row lands after AutoMigrate adds the columns. Verification runs after the capped body read and before the first write, so a rejected request leaves no event row, no delivery row and no delivery task. A configuration the receiver cannot apply — unknown scheme, or one half of the pair missing — is refused with a 500 rather than falling back to unverified. The secret is credential-bearing and is stored in the clear because HMAC needs the key itself. It is excluded from JSON, kept out of templates by a new handlers.EntrypointView projection, and absent from every log line including the rejection path. The UI sets and rotates it through one form that never renders the stored value. Under the GitLab scheme the signature header is the secret rather than a digest over the request, so an accepted request's headers are cloned and the configured scheme's credential header dropped before they are serialized onto the event. Stored headers are persisted verbatim in the per-webhook database and replayed onto every outbound delivery, so keeping the token would put it in every backup and hand every target operator the means to forge signed requests to the entrypoint it authenticates. Stripping sits once above the first write rather than at each egress, and is driven by the scheme's own description with stripping as the default: a scheme added later is covered unless it declares its header a digest, as GitHub's HMAC over the body does. An entrypoint holding one half of the pair now renders as misconfigured rather than as unverified, and the scheme selector follows the stored scheme so such a row no longer marks two options selected.
This commit is contained in:
144
README.md
144
README.md
@@ -516,7 +516,110 @@ backups at rest and restrict who can read them.
|
||||
`events-*.db` today hands them live delivery destinations.
|
||||
- `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
|
||||
|
||||
@@ -793,9 +896,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
|
||||
@@ -1065,12 +1174,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)
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
@@ -1831,6 +1944,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 |
|
||||
@@ -1877,7 +1991,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)
|
||||
@@ -1912,6 +2026,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
|
||||
@@ -1936,9 +2051,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)
|
||||
@@ -2080,6 +2197,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