Documentation
¶
Overview ¶
Package webhook verifies signed incoming webhooks: Middleware checks an HMAC-SHA256 of the raw body (GitHub, Shopify, your own integrations), Standard checks Standard Webhooks signatures (Svix, Resend, Clerk) with a timestamp replay window, and Dedupe drops at-least-once redeliveries.
verify, err := webhook.HMAC("X-Hub-Signature-256", secret, 1<<20) // error on an empty secret
r.Post("/webhooks/github", handle).Use(verify)
HMAC and Middleware bound the body, verifies the signature header and answers 401 (ErrSignature) on mismatch; VerifyHMAC and Sign are the building blocks for custom schemes and tests.
Outgoing webhooks: Sender delivers Delivery payloads from queue jobs, signed with the Standard Webhooks scheme (NewSecret, SignStandard), through httpclient.Safe with retries and backoff; SQLDeliveryLog records each attempt.
See https://github.com/AlexGalhardo/vukem/blob/main/docs/security.md.
Example ¶
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/AlexGalhardo/vukem/webhook"
)
func main() {
secret := []byte("whsec_example")
handler := webhook.Middleware("X-Hub-Signature-256", secret, 1<<20)(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
fmt.Fprintf(w, "accepted %s", body)
}),
)
payload := `{"action":"opened"}`
req := httptest.NewRequest(http.MethodPost, "/webhooks/github", strings.NewReader(payload))
req.Header.Set("X-Hub-Signature-256", webhook.Sign(secret, []byte(payload)))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
fmt.Println(rec.Code, rec.Body.String())
forged := httptest.NewRequest(http.MethodPost, "/webhooks/github", strings.NewReader(payload))
forged.Header.Set("X-Hub-Signature-256", "sha256=00")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, forged)
fmt.Println(rec.Code)
}
Output: 200 accepted {"action":"opened"} 401
Index ¶
- Constants
- Variables
- func Dedupe(store cache.Store, id func(*http.Request) string, o DedupeOptions) func(http.Handler) http.Handler
- func HMAC(header string, secret []byte, maxBody int64) (func(http.Handler) http.Handler, error)
- func Middleware(header string, secret []byte, maxBody int64) func(http.Handler) http.Handler
- func Migrations(table string) []schema.Migration
- func NewSecret() (string, error)
- func Sign(secret, payload []byte) string
- func SignStandard(secret, id string, at time.Time, body []byte) (string, error)
- func Standard(secret string, o StandardOptions) (func(http.Handler) http.Handler, error)
- func StandardID(r *http.Request) string
- func VerifyHMAC(secret, payload []byte, signature string) bool
- type Attempt
- type DedupeOptions
- type Delivery
- type DeliveryLog
- type DeliveryLogFunc
- type Endpoint
- type SQLDeliveryLog
- type Sender
- type SenderOptions
- type StandardOptions
Examples ¶
Constants ¶
const ( // AttemptBlockedAddress means the URL resolved to an internal address. AttemptBlockedAddress = "blocked_address" // AttemptTimeout means the attempt timed out or was cancelled. AttemptTimeout = "timeout" // AttemptRefused means the connection was refused. AttemptRefused = "refused" // AttemptTLS means the TLS handshake or certificate check failed. AttemptTLS = "tls" // AttemptNetwork is any other transport error (DNS, reset…). AttemptNetwork = "network" // AttemptHTTPStatus means the endpoint answered a non-2xx status. AttemptHTTPStatus = "http_status" // AttemptInvalid means an invalid delivery id, endpoint URL or secret. AttemptInvalid = "invalid" // AttemptDisabled means the endpoint was disabled (ErrEndpointDisabled) // and the delivery was dropped without a request. AttemptDisabled = "disabled" )
Attempt.Error categories, safe to show to endpoint owners.
const DefaultDeliveryTable = "webhook_deliveries"
DefaultDeliveryTable is the table SQLDeliveryLog owns when NewSQLDeliveryLog is called with an empty name. It is listed with every other table vukem owns in docs/database.md#tables-the-framework-owns.
const DeliverKind = "webhook.deliver"
DeliverKind is the queue job kind of outgoing webhook deliveries.
const MigrationID = "20261018000003_vukem_create_webhook_deliveries_table"
MigrationID is the id of the migration Migrations returns; it names the framework's own migration in vukem migrate:status.
Variables ¶
var ErrEndpointDisabled = errors.New("webhook: endpoint disabled")
ErrEndpointDisabled is returned by SenderOptions.Endpoint for an endpoint that no longer receives deliveries; pending deliveries to it are dropped instead of retried.
var ErrSignature = &errs.HTTPError{Status: http.StatusUnauthorized, Message: "Invalid webhook signature."}
ErrSignature is the 401 answered for missing or wrong signatures.
Functions ¶
func Dedupe ¶ added in v0.22.0
func Dedupe(store cache.Store, id func(*http.Request) string, o DedupeOptions) func(http.Handler) http.Handler
Dedupe runs the handler at most once per event id: providers deliver at least once, so a redelivery of a handled event gets 200 with "Webhook-Duplicate: true" without running the handler again, and a duplicate that arrives while the first is running gets 409 (the provider retries). Deliveries answered with a status of 300 or above are forgotten so the retry runs. A missing id is a 400, and a store failure is a 503 (fail closed). Keys are namespaced (DedupeOptions.Namespace), so two providers that reuse an event id do not drop each other's deliveries. Put it after the signature check.
func HMAC ¶ added in v0.24.0
HMAC is Middleware that checks its configuration first: an empty header or secret (an unset environment variable) is an error at boot instead of a webhook that answers 401 forever. Prefer it to Middleware.
func Middleware ¶
Middleware verifies the signature in header against the raw body (at most maxBody bytes, default 1 MiB when maxBody <= 0) and restores the body for the handler. Failures get 401 and a "webhook.signature_invalid" security event. It does not bound replays; use Standard when the provider signs a timestamp, and Dedupe to drop redeliveries.
Middleware is the lenient form: an empty secret is not reported and every request is refused. HMAC reports it at boot.
func Migrations ¶ added in v0.29.0
Migrations returns the migration that creates the delivery log table (the default when table is empty), so an app can manage it with vukem migrate instead of leaving it to the boot-time CreateTable. The statements are the ones CreateTable runs, which stays as the fallback for apps that do not register it.
Register it only when the app uses SQLDeliveryLog: an app with its own DeliveryLog implementation owns its table and should leave this one out. On a database where the table already exists (the log created it before the app opted in) the migration records itself and changes nothing; Down drops the table.
func NewSecret ¶ added in v0.23.0
NewSecret returns a random signing secret in the Standard Webhooks format, "whsec_" followed by 32 base64 bytes. Give it to the receiver once and store it with the endpoint.
func SignStandard ¶ added in v0.23.0
SignStandard returns the webhook-signature value ("v1,<base64>") for a delivery with id sent at at, as Standard verifies it.
func Standard ¶ added in v0.22.0
Standard verifies the Standard Webhooks scheme (https://www.standardwebhooks.com, used by Svix, Resend, Clerk and others): webhook-signature carries one or more "v1,<base64 HMAC-SHA256>" over "<webhook-id>.<webhook-timestamp>.<body>", and webhook-timestamp must be within Tolerance. secret is the provider's "whsec_<base64>" value (the prefix is optional). A malformed secret is an error at boot rather than a webhook that never verifies.
func StandardID ¶ added in v0.22.0
StandardID returns the webhook-id header, the event id for Dedupe with Standard Webhooks providers.
func VerifyHMAC ¶
VerifyHMAC reports whether signature is the HMAC-SHA256 of payload with secret. The signature may be hex or base64 and may carry a "sha256=" prefix. The comparison is constant time.
Example ¶
package main
import (
"fmt"
"github.com/AlexGalhardo/vukem/webhook"
)
func main() {
secret := []byte("whsec_example")
sig := webhook.Sign(secret, []byte("hello"))
fmt.Println(webhook.VerifyHMAC(secret, []byte("hello"), sig))
fmt.Println(webhook.VerifyHMAC(secret, []byte("hello!"), sig))
}
Output: true false
Types ¶
type Attempt ¶ added in v0.23.0
type Attempt struct {
Endpoint string
EventID string
// Status is the HTTP status, 0 when no response arrived.
Status int
Duration time.Duration
// Error is empty on success or one of the Attempt* categories.
Error string
At time.Time
}
Attempt is one outgoing delivery attempt. It holds no URL, secret or payload.
type DedupeOptions ¶ added in v0.22.0
type DedupeOptions struct {
// TTL remembers handled event ids (default 72h, longer than providers'
// retry windows).
TTL time.Duration
// LockTTL bounds a delivery that is still running (default 1 minute).
LockTTL time.Duration
// Namespace keeps event ids of different providers or handlers apart
// on a shared store (default: the matched ServeMux route pattern).
// Outside a route (Dedupe as a global middleware) the default is one
// fixed namespace, never the client-controlled path, so all providers
// share it there: put Dedupe on the route, or set Namespace. Set it
// too when one route serves several providers, or to keep keys stable
// across a route rename.
Namespace string
}
DedupeOptions tune Dedupe.
type Delivery ¶ added in v0.23.0
type Delivery struct {
// ID is the webhook-id header, stable across retries so receivers can
// drop duplicates; Send generates "msg_<random>" when empty.
ID string `json:"id"`
// Endpoint identifies the endpoint for SenderOptions.Endpoint.
Endpoint string `json:"endpoint"`
// Payload is the JSON body. It is stored inside the job JSON, so
// insignificant whitespace may be compacted; the signature covers the
// bytes actually sent.
Payload json.RawMessage `json:"payload"`
}
Delivery is one event for one endpoint, the payload of the queue job. It carries the endpoint id, never its URL or secret, so neither is stored in the jobs tables.
type DeliveryLog ¶ added in v0.23.0
DeliveryLog stores delivery attempts, for example to show a seller why their endpoint is failing. It is the extension point for apps that already have a deliveries table: implement Record (or wrap a function in DeliveryLogFunc) and map Attempt onto your own columns; SQLDeliveryLog is one implementation with a fixed schema.
The Sender calls Record after every attempt, from concurrent queue workers, with a context that outlives the job (5s timeout). A Record error is logged and never retries or fails the delivery.
type DeliveryLogFunc ¶ added in v0.25.0
DeliveryLogFunc adapts a function to DeliveryLog.
Example ¶
An app with its own deliveries table maps Attempt onto its columns instead of using SQLDeliveryLog, for example:
_, err := db.ExecContext(ctx, `INSERT INTO seller_webhook_deliveries (seller_id, message_id, http_status, ok, failure, created_at) VALUES (?, ?, ?, ?, ?, ?)`, a.Endpoint, a.EventID, a.Status, a.Error == "", a.Error, a.At)
package main
import (
"context"
"fmt"
"github.com/AlexGalhardo/vukem/webhook"
)
func main() {
log := webhook.DeliveryLogFunc(func(_ context.Context, a webhook.Attempt) error {
fmt.Printf("seller=%s message=%s status=%d ok=%t\n", a.Endpoint, a.EventID, a.Status, a.Error == "")
return nil
})
sender, err := webhook.NewSender(webhook.SenderOptions{
Endpoint: func(context.Context, string) (webhook.Endpoint, error) {
return webhook.Endpoint{}, webhook.ErrEndpointDisabled
},
Log: log,
})
if err != nil {
fmt.Println(err)
return
}
_ = sender
_ = log.Record(context.Background(), webhook.Attempt{Endpoint: "seller-1", EventID: "msg_1", Status: 200})
}
Output: seller=seller-1 message=msg_1 status=200 ok=true
type Endpoint ¶ added in v0.23.0
Endpoint is where a delivery goes. URL is usually user supplied (a seller's webhook URL); Secret is its "whsec_..." signing secret.
type SQLDeliveryLog ¶ added in v0.23.0
type SQLDeliveryLog struct {
// contains filtered or unexported fields
}
SQLDeliveryLog keeps attempts in a table (SQLite, Postgres or MySQL): id, endpoint, event_id, status, duration_ms, error and attempted_at in Unix milliseconds. CreateTable creates it. Prune old rows with a scheduled DELETE if the table grows too large. Only the table name is configurable; to write into an existing table with other columns, implement DeliveryLog instead.
func NewSQLDeliveryLog ¶ added in v0.23.0
func NewSQLDeliveryLog(db *database.DB, table string) (*SQLDeliveryLog, error)
NewSQLDeliveryLog returns a log on table (default "webhook_deliveries").
func (*SQLDeliveryLog) CreateTable ¶ added in v0.23.0
func (l *SQLDeliveryLog) CreateTable(ctx context.Context) error
CreateTable creates the table and its (endpoint, attempted_at) index if missing. It is the fallback for apps that do not register Migrations: the statements are the same, so running it after the migration changes nothing.
type Sender ¶ added in v0.23.0
type Sender struct {
// contains filtered or unexported fields
}
Sender delivers signed outgoing webhooks from queue jobs: register Sender.Job with the queue and call Sender.Send. Each attempt is signed with the Standard Webhooks scheme (webhook-id, webhook-timestamp, webhook-signature) and sent through httpclient.Safe, so endpoints on loopback, private or metadata addresses are refused. A 2xx answer is a success, 410 Gone stops for good, and anything else is retried with backoff until Retries runs out and the job moves to the failed jobs.
func NewSender ¶ added in v0.23.0
func NewSender(o SenderOptions) (*Sender, error)
NewSender returns a Sender; a missing Endpoint lookup is an error.
func (*Sender) Deliver ¶ added in v0.23.0
Deliver makes one attempt and records it. It returns nil when the delivery is done: 2xx, 410 Gone, 3xx (redirects are not followed), a disabled endpoint or an invalid delivery id. Any other outcome is an error so the queue retries it. Errors never contain the URL or secret.
func (*Sender) Job ¶ added in v0.23.0
func (s *Sender) Job() queue.Registration
Job is the queue registration for deliveries; pass it to Queue.Register (or vukem.WithJobs).
type SenderOptions ¶ added in v0.23.0
type SenderOptions struct {
// Endpoint looks up an endpoint by id at each attempt (required), so a
// rotated secret or a changed URL applies to pending retries. Return
// ErrEndpointDisabled to drop deliveries to it.
Endpoint func(ctx context.Context, id string) (Endpoint, error)
// Log records every attempt (optional); see SQLDeliveryLog.
Log DeliveryLog
// OnGone runs when an endpoint answers 410 Gone, for example to
// disable it. Deliveries answered with 410 are not retried; an OnGone
// error is logged and not retried either.
OnGone func(ctx context.Context, endpoint string) error
// Logger receives delivery outcomes without URLs, secrets or payloads
// (default slog.Default()).
Logger *slog.Logger
// Timeout bounds one HTTP attempt (default 15s).
Timeout time.Duration
// Retries is how many times a failed delivery is retried (0 means the
// default, 8; a negative value means no retries).
Retries int
// Backoff is the delay before retry n (default exponential from 30s,
// capped at one hour: 30s, 1m, 2m … about two hours in total).
Backoff func(attempt int) time.Duration
// Now is the clock for webhook-timestamp (default time.Now).
Now func() time.Time
// InsecureAllowHTTP permits plain http:// endpoints, which expose
// payloads and signatures on the wire. For tests and local tools.
InsecureAllowHTTP bool
// InsecureAllow lists internal prefixes deliveries may reach (for
// example 127.0.0.0/8 for an httptest server). Endpoints are user
// supplied, so leave it empty in production.
InsecureAllow []netip.Prefix
}
SenderOptions configure a Sender.
type StandardOptions ¶ added in v0.22.0
type StandardOptions struct {
// MaxBody bounds the body (default 1 MiB).
MaxBody int64
// Tolerance is how far webhook-timestamp may be from now, in either
// direction (default 5 minutes). It bounds replays of captured requests.
Tolerance time.Duration
// Now is the clock (default time.Now).
Now func() time.Time
}
StandardOptions tune Standard.