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:
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
@@ -10,6 +11,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 (
|
||||
@@ -69,8 +71,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// processWebhookRequest reads the body, serializes headers,
|
||||
// loads targets, and delivers the event.
|
||||
// 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,
|
||||
@@ -81,7 +83,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)
|
||||
|
||||
@@ -100,6 +121,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,
|
||||
|
||||
Reference in New Issue
Block a user