package webhooks import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "net/http" "atlas9.dev/c/demo/lib/access" ) // SignatureHeader carries the HMAC-SHA256 of the request body, hex-encoded and // prefixed with the algorithm, so a receiver can verify authenticity with the // endpoint's secret. const SignatureHeader = "X-Webhook-Signature" // EventHeader names the event type on a delivered request, so a receiver can // route without parsing the body (cf. GitHub's X-GitHub-Event). const EventHeader = "X-Webhook-Event" // Sender is the guarded egress layer for outbound webhooks, the analog of // *mail.Mailer: it enforces the egress cap, signs the body, and POSTs it, so // every delivery crosses one capability-checked chokepoint. Delivery is only // ever HTTP, so it wraps an *http.Client directly rather than an abstract // transport; tests point an endpoint's URL at an httptest.Server. type Sender struct { // Client sends the request. A nil Client uses http.DefaultClient; configure // one with a timeout so a slow endpoint can't tie up a delivery worker. Client *http.Client Guard access.Guard } // Deliver signs body with the endpoint's secret and POSTs it to the endpoint's // URL, returning the response status code. It is the chokepoint: every delivery // crosses the egress boundary here, so Cap_Webhooks_Send is checked exactly once. func (s *Sender) Deliver(ctx context.Context, endpoint Endpoint, secret []byte, eventType string, body []byte) (int, error) { if err := s.Guard.System(ctx, Cap_Webhooks_Send); err != nil { return 0, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.URL, bytes.NewReader(body)) if err != nil { return 0, err } req.Header.Set("Content-Type", "application/json") req.Header.Set(SignatureHeader, Sign(secret, body)) req.Header.Set(EventHeader, eventType) client := s.Client if client == nil { client = http.DefaultClient } resp, err := client.Do(req) if err != nil { return 0, fmt.Errorf("webhook: %w", err) } defer resp.Body.Close() return resp.StatusCode, nil } // Sign returns the signature header value for body under secret. func Sign(secret, body []byte) string { mac := hmac.New(sha256.New, secret) mac.Write(body) return "sha256=" + hex.EncodeToString(mac.Sum(nil)) }