diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md new file mode 100644 index 00000000000..68f4cb6c6bd --- /dev/null +++ b/examples/webhooks/README.md @@ -0,0 +1,111 @@ +# Async Webhooks Receiver + +A minimal, dependency-free Go receiver for Bifrost async inference webhooks. Its +only job is to demonstrate the one thing every receiver must get right: +**verifying the signature before trusting a delivery.** + +When an async inference job reaches a terminal state, Bifrost POSTs a signed +event to every subscribed endpoint. The signature proves the request came from +your Bifrost instance and that the body was not altered in transit. + +## What Bifrost sends + +Each delivery is a `POST` with a JSON body and three signing headers: + +| Header | Meaning | +| ------------------- | -------------------------------------------------------------- | +| `webhook-id` | Unique id for this delivery — also the **dedupe key**. | +| `webhook-timestamp` | Unix seconds the payload was signed at. | +| `webhook-signature` | Space-separated list of `v1,` signatures. | + +The body looks like: + +```json +{ + "event": "async_job.completed", + "created_at": "2026-07-16T10:00:00Z", + "data": { + "job_id": "job_abc123", + "request_type": "chat_completion", + "status": "completed", + "status_code": 200, + "result_url": "/v1/async/chat/completions/job_abc123", + "result_expires_at": "2026-07-17T10:00:00Z" + } +} +``` + +Events are `async_job.completed` and `async_job.failed`. The `data` fields +beyond `job_id` and `status` are best-effort: + +- `result_url` — GET this (through Bifrost, with your auth) to fetch the full + result. Valid until `result_expires_at`. +- `response` — the full response, inlined **only** if the endpoint opted in and + it fits the size limit. +- `response_omitted: true` — the response was too large to inline; fetch it via + `result_url` instead. +- `result_expired: true` — the job's result was already gone when this delivery + fired. The outcome is known, but there is nothing left to fetch. + +## Verifying deliveries + +The signature is HMAC-SHA256 over the exact bytes `{id}.{timestamp}.{body}`, +keyed with your endpoint's signing secret, encoded as `v1,`. To verify: + +1. Recompute the HMAC from the secret and the received `id`, `timestamp`, and + raw body, and compare it (in constant time) against every candidate in the + `webhook-signature` header. Accept if **any** matches — the header can carry + more than one signature during secret rotation. +2. Reject if `webhook-timestamp` is outside a tolerance window (this example + uses 5 minutes) to blunt replay attacks. +3. **Dedupe on `webhook-id`.** Delivery is at-least-once: retries reuse the same + id, so you can receive the same event more than once. + +The secret (`whsec_...`) is shown **once** when you create the endpoint, and can +only be changed by rotating it. Store it somewhere your receiver can read it; +never hard-code it. + +See [`main.go`](./main.go) for the full implementation — `verify` and `sign` +are ~40 lines of standard library. + +## Requiring custom headers + +Bifrost endpoints can be configured to send custom headers with every delivery +(for example an `Authorization` value). The signature alone already proves +authenticity, but checking such headers is cheap defense-in-depth: it rejects +unwanted traffic before any crypto runs. + +Set `REQUIRED_HEADERS` to comma-separated `Name=Value` pairs matching the +headers configured on the endpoint: + +```bash +WEBHOOK_SECRET=whsec_... REQUIRED_HEADERS='Authorization=Bearer s3cret,X-Env=prod' go run . +``` + +Deliveries missing any pair — or carrying a different value — are rejected with +a generic `401` before signature verification. Values are compared in constant +time, since they are often bearer credentials. Values may contain `=` but not +`,`. + +## Run it + +```bash +WEBHOOK_SECRET=whsec_your_secret_here go run . +``` + +The receiver listens on `:8080` (override with `ADDR`) and accepts deliveries at +`POST /webhook`. Point a Bifrost webhook endpoint at `http://:8080/webhook` +and complete a job to see verified deliveries logged. + +> Plain `http://` endpoints are only accepted by Bifrost when the endpoint has +> `allow_private_network` set. Use `https://` in production. + +## Test + +```bash +go test ./... +``` + +The tests pin the canonical Standard Webhooks reference vector — the same one +Bifrost's own signer pins — so a passing run proves this receiver verifies +byte-for-byte what Bifrost signs. diff --git a/examples/webhooks/go.mod b/examples/webhooks/go.mod new file mode 100644 index 00000000000..7e7e1e14fb5 --- /dev/null +++ b/examples/webhooks/go.mod @@ -0,0 +1,3 @@ +module github.com/maximhq/bifrost/examples/webhooks + +go 1.24 diff --git a/examples/webhooks/main.go b/examples/webhooks/main.go new file mode 100644 index 00000000000..2d7769be762 --- /dev/null +++ b/examples/webhooks/main.go @@ -0,0 +1,263 @@ +// Command webhooks is a minimal, dependency-free receiver for Bifrost async +// inference webhooks. It shows the one thing every receiver must get right: +// verifying the Standard Webhooks signature before trusting a delivery. +// +// Bifrost signs each delivery with HMAC-SHA256 over "{id}.{timestamp}.{body}" +// keyed with the endpoint's signing secret, and sends three headers: +// +// webhook-id unique id for this delivery (also the dedupe key) +// webhook-timestamp unix seconds the payload was signed at +// webhook-signature space-separated list of "v1," signatures +// +// Retries reuse the same webhook-id, so at-least-once delivery means you can +// receive the same id more than once — dedupe on it. The signature header may +// carry multiple values (e.g. during secret rotation); accept the delivery if +// ANY of them verifies. +// +// Endpoints can also be configured to send custom headers with every delivery +// (for example an Authorization value). To have this receiver require them, +// list the expected pairs in REQUIRED_HEADERS; deliveries missing any of them +// are rejected before signature verification. +// +// Run it against your endpoint's secret: +// +// WEBHOOK_SECRET=whsec_... REQUIRED_HEADERS='Authorization=Bearer s3cret' go run . +// +// then point a Bifrost webhook endpoint at http://:8080/webhook. +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// tolerance bounds how far a delivery's timestamp may drift from now before we +// reject it as a possible replay. Standard Webhooks recommends five minutes. +const tolerance = 5 * time.Minute + +// maxBodyBytes caps the request body we will read and sign over, so a +// malicious sender cannot exhaust memory. Match or exceed the largest payload +// your endpoints emit (endpoints that inline responses can be larger). +const maxBodyBytes = 1 << 20 // 1 MiB + +// eventEnvelope mirrors the JSON body Bifrost delivers. Only a subset of Data +// is populated for any given event; see the field comments. +type eventEnvelope struct { + Event string `json:"event"` // "async_job.completed" | "async_job.failed" + CreatedAt time.Time `json:"created_at"` // when this delivery was rendered + Data struct { + JobID string `json:"job_id"` + RequestType string `json:"request_type,omitempty"` + Status string `json:"status"` + StatusCode int `json:"status_code,omitempty"` + ResultURL string `json:"result_url,omitempty"` // GET this to fetch the result + ResultExpiresAt *time.Time `json:"result_expires_at,omitempty"` // after which result_url is dead + Response json.RawMessage `json:"response,omitempty"` // inlined only if the endpoint opted in + ResponseOmitted bool `json:"response_omitted,omitempty"` // response too large to inline; fetch it + ResultExpired bool `json:"result_expired,omitempty"` // result gone before delivery; nothing to fetch + } `json:"data"` +} + +type receiver struct { + secret string + // requiredHeaders are custom delivery headers this receiver insists on, + // matching the headers configured on the Bifrost endpoint. Values are + // compared in constant time — they are often bearer credentials. + requiredHeaders map[string]string +} + +func (r *receiver) handle(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := checkRequiredHeaders(req.Header, r.requiredHeaders); err != nil { + // Same rule as signature failures: log the reason, but answer with a + // generic 4xx so a probing sender learns nothing about what is checked. + log.Printf("rejected delivery: %v", err) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + // Read one byte past the cap so an oversized body is detected rather than + // silently truncated: verifying over truncated bytes would reject every + // otherwise-valid delivery. Answer such deliveries with an explicit 413. + body, err := io.ReadAll(io.LimitReader(req.Body, maxBodyBytes+1)) + if err != nil { + http.Error(w, "cannot read body", http.StatusBadRequest) + return + } + if len(body) > maxBodyBytes { + http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) + return + } + + id := req.Header.Get("webhook-id") + ts := req.Header.Get("webhook-timestamp") + sig := req.Header.Get("webhook-signature") + + if err := verify(r.secret, id, ts, sig, body, time.Now()); err != nil { + // Return 4xx so Bifrost records the failure. Do NOT echo the reason in + // production — it can help an attacker probe your verification. + log.Printf("rejected delivery id=%q: %v", id, err) + http.Error(w, "signature verification failed", http.StatusUnauthorized) + return + } + + var envelope eventEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + // The signature is valid — this is a genuine Bifrost delivery. Dedupe on + // `id` (retries reuse it) before doing any real work, then process. Here we + // just log a summary. + log.Printf("accepted id=%s event=%s job=%s status=%s result_url=%s", + id, envelope.Event, envelope.Data.JobID, envelope.Data.Status, envelope.Data.ResultURL) + if envelope.Data.ResponseOmitted { + log.Printf(" response omitted (too large) — GET %s to fetch it", envelope.Data.ResultURL) + } + if envelope.Data.ResultExpired { + log.Printf(" result expired before delivery — outcome known, result gone") + } + + // Any 2xx tells Bifrost the delivery succeeded. Return quickly and do slow + // work asynchronously so retries are not triggered by your own latency. + w.WriteHeader(http.StatusNoContent) +} + +// verify checks a delivery's Standard Webhooks signature. It returns nil only +// when the timestamp is within tolerance AND at least one of the signatures in +// the header matches the one we recompute from the secret. +func verify(secret, id, timestamp, signatureHeader string, body []byte, now time.Time) error { + if id == "" || timestamp == "" || signatureHeader == "" { + return fmt.Errorf("missing webhook-id/webhook-timestamp/webhook-signature header") + } + + secs, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return fmt.Errorf("invalid webhook-timestamp: %w", err) + } + drift := now.Sub(time.Unix(secs, 0)) + if drift < 0 { + drift = -drift + } + if drift > tolerance { + return fmt.Errorf("timestamp outside tolerance (%s drift)", drift) + } + + expected, err := sign(secret, id, secs, body) + if err != nil { + return err + } + + // The header is a space-separated list of "v1," signatures. Compare + // every candidate against the expected one in constant time, and accept if + // any matches. Constant-time compare avoids leaking the secret via timing. + expectedBytes := []byte(expected) + for _, candidate := range strings.Split(signatureHeader, " ") { + if candidate == "" { + continue + } + if subtle.ConstantTimeCompare([]byte(candidate), expectedBytes) == 1 { + return nil + } + } + return fmt.Errorf("no signature matched") +} + +// checkRequiredHeaders returns nil only when every required header is present +// with exactly the expected value. Values are compared in constant time so a +// header carrying a credential cannot be guessed byte-by-byte via timing. +func checkRequiredHeaders(h http.Header, required map[string]string) error { + for name, want := range required { + got := h.Get(name) + if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 { + return fmt.Errorf("required header %q missing or mismatched", name) + } + } + return nil +} + +// parseRequiredHeaders parses REQUIRED_HEADERS: comma-separated Name=Value +// pairs, e.g. "Authorization=Bearer s3cret,X-Env=prod". Values may contain +// "=" but not ",". +func parseRequiredHeaders(s string) (map[string]string, error) { + required := map[string]string{} + for pair := range strings.SplitSeq(s, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + name, value, ok := strings.Cut(pair, "=") + name = strings.TrimSpace(name) + if !ok || name == "" { + return nil, fmt.Errorf("malformed pair %q: want Name=Value", pair) + } + required[name] = value + } + return required, nil +} + +// sign recomputes the "v1," signature for one delivery, mirroring how +// Bifrost signs it. The secret's whsec_ prefix is stripped and the remainder +// base64-decoded to obtain the raw HMAC key. +func sign(secret, id string, timestamp int64, body []byte) (string, error) { + if secret == "" { + return "", fmt.Errorf("signing secret is empty") + } + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + if err != nil { + return "", fmt.Errorf("invalid signing secret: %w", err) + } + mac := hmac.New(sha256.New, key) + fmt.Fprintf(mac, "%s.%d.", id, timestamp) + mac.Write(body) + return "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func main() { + secret := os.Getenv("WEBHOOK_SECRET") + if secret == "" { + log.Fatal("set WEBHOOK_SECRET to the endpoint's signing secret (whsec_...)") + } + addr := os.Getenv("ADDR") + if addr == "" { + addr = ":8080" + } + requiredHeaders, err := parseRequiredHeaders(os.Getenv("REQUIRED_HEADERS")) + if err != nil { + log.Fatalf("invalid REQUIRED_HEADERS: %v", err) + } + + r := &receiver{secret: secret, requiredHeaders: requiredHeaders} + mux := http.NewServeMux() + mux.HandleFunc("/webhook", r.handle) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + + // Timeouts bound how long a single (unauthenticated) connection may hold a + // handler goroutine, so a slow or stalled client cannot exhaust the server + // and starve legitimate deliveries. + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Printf("webhook receiver listening on %s (POST /webhook)", addr) + log.Fatal(srv.ListenAndServe()) +} diff --git a/examples/webhooks/main_test.go b/examples/webhooks/main_test.go new file mode 100644 index 00000000000..6ee553148d5 --- /dev/null +++ b/examples/webhooks/main_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "net/http" + "reflect" + "strings" + "testing" + "time" +) + +// referenceTime is the fixed "now" used by the vector tests so a delivery +// signed at the vector's timestamp is always within tolerance. +var referenceTime = time.Unix(1614265330, 0) + +// TestSignReferenceVector pins the canonical Standard Webhooks example. This is +// the SAME vector Bifrost's own signer test pins, so a match here proves this +// receiver verifies exactly what Bifrost signs. +func TestSignReferenceVector(t *testing.T) { + got, err := sign( + "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + "msg_p5jXN8AQM9LWM0D4loKWxJek", + 1614265330, + []byte(`{"test": 2432232314}`), + ) + if err != nil { + t.Fatalf("sign: %v", err) + } + const want = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=" + if got != want { + t.Fatalf("signature mismatch:\n got %q\nwant %q", got, want) + } +} + +func TestVerifyAcceptsValidDelivery(t *testing.T) { + secret := "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" + body := []byte(`{"event":"async_job.completed"}`) + sig, err := sign(secret, "msg_1", referenceTime.Unix(), body) + if err != nil { + t.Fatalf("sign: %v", err) + } + if err := verify(secret, "msg_1", "1614265330", sig, body, referenceTime); err != nil { + t.Fatalf("valid delivery rejected: %v", err) + } +} + +func TestVerifyAcceptsOneOfMultipleSignatures(t *testing.T) { + secret := "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" + body := []byte(`{}`) + valid, err := sign(secret, "msg_1", referenceTime.Unix(), body) + if err != nil { + t.Fatalf("sign: %v", err) + } + // During secret rotation the header can carry several signatures; any match + // must be accepted. + header := strings.Join([]string{"v1,AAAA", valid, "v1,BBBB"}, " ") + if err := verify(secret, "msg_1", "1614265330", header, body, referenceTime); err != nil { + t.Fatalf("multi-signature delivery rejected: %v", err) + } +} + +func TestVerifyRejects(t *testing.T) { + secret := "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" + body := []byte(`{"a":1}`) + sig, err := sign(secret, "msg_1", referenceTime.Unix(), body) + if err != nil { + t.Fatalf("sign: %v", err) + } + + cases := []struct { + name string + id, ts, sig string + body []byte + now time.Time + }{ + {"tampered body", "msg_1", "1614265330", sig, []byte(`{"a":2}`), referenceTime}, + {"wrong id", "msg_2", "1614265330", sig, body, referenceTime}, + {"forged signature", "msg_1", "1614265330", "v1,deadbeef", body, referenceTime}, + {"stale timestamp", "msg_1", "1614265330", sig, body, referenceTime.Add(10 * time.Minute)}, + {"missing headers", "", "", "", body, referenceTime}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := verify(secret, tc.id, tc.ts, tc.sig, tc.body, tc.now); err == nil { + t.Fatalf("expected rejection, got nil") + } + }) + } +} + +func TestParseRequiredHeaders(t *testing.T) { + got, err := parseRequiredHeaders("Authorization=Bearer s3cret, X-Env=prod") + if err != nil { + t.Fatalf("parseRequiredHeaders: %v", err) + } + want := map[string]string{"Authorization": "Bearer s3cret", "X-Env": "prod"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parsed headers mismatch:\n got %v\nwant %v", got, want) + } + + if _, err := parseRequiredHeaders("no-equals-sign"); err == nil { + t.Fatal("expected error for pair without '='") + } + if got, err := parseRequiredHeaders(""); err != nil || len(got) != 0 { + t.Fatalf("empty input: got %v, %v; want empty map, nil", got, err) + } +} + +func TestCheckRequiredHeaders(t *testing.T) { + required := map[string]string{"Authorization": "Bearer s3cret"} + + h := http.Header{} + h.Set("Authorization", "Bearer s3cret") + if err := checkRequiredHeaders(h, required); err != nil { + t.Fatalf("matching headers rejected: %v", err) + } + + if err := checkRequiredHeaders(http.Header{}, required); err == nil { + t.Fatal("expected rejection when required header is missing") + } + + h.Set("Authorization", "Bearer wrong") + if err := checkRequiredHeaders(h, required); err == nil { + t.Fatal("expected rejection when required header value mismatches") + } + + if err := checkRequiredHeaders(http.Header{}, nil); err != nil { + t.Fatalf("nil required set must accept everything: %v", err) + } +}