webhook

package
v0.1.0-develop.2026061... Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Overview

Package webhook is the kernel-level pure-computation core for GoCell's bidirectional webhook capability (KERNEL-WEBHOOK-01): inbound receiver verification and outbound dispatcher signing. It depends only on the standard library + pkg/errcode + pkg/redaction + kernel/clock in production (kernel/ layering rule — no runtime/ adapters/ cells/); pkg/panicregister is a test-only dependency of webhook_helpers_test.go.

Scope: L0 (signature compute + verify are pure functions of input bytes). The HTTP receiver middleware and the outbox dispatcher consumer that wire these primitives into request/delivery flows live in runtime/webhook (PR-3 / PR-5).

Core surface

  • Algorithm — signing algorithm marker; the only legal value is AlgorithmHMACSHA256. Algorithm.Validate rejects everything else with errcode.ErrWebhookAlgorithmUnsupported.
  • SourceID / DeliveryID — typed string newtypes with NewSourceID / NewDeliveryID validators. Mirror the kernel/healthz.ProbeName funnel shape. (Panic-on-error Must* constructors are test-only fixtures in webhook_helpers_test.go, not part of the production surface.)
  • Source — an opaque (id, secret) pair. Its secret field is unexported with no getter, and Source.LogValue redacts it, so a webhook secret can never reach slog/spans by accident — see the secret-leak defenses below.
  • Headers — the INBOUND signature-header DTO (delivery id, timestamp, signature) that a Verifier consumes; the receiver builds it from raw request headers (untrusted input), so its fields are exported.
  • SignedHeaders — the OUTBOUND, provenance-sealed counterpart that Signer.Sign produces and SignedHeaders.Apply writes to the wire. Its fields are unexported, incl. a `valid` provenance flag only Sign sets, and Apply fail-closes on a zero value — so only a Sign-produced value reaches the wire (WEBHOOK-SIGNER-FUNNEL-01 upstream Hard external; #1492 + #1733 F1).
  • Signer / Verifier — sealed interfaces (unexported sealed() marker) so package-external implementations are a compile error. The sole in-package implementations are the HMAC-SHA256 signer/verifier built by NewHMACSigner / NewHMACVerifier; Signer.Sign returns a SignedHeaders.
  • SourceStore / SourceRegistry — secret lookup interface + in-memory implementation (externally replaceable; persistent stores are a follow-up).

Signing scheme (Svix-aligned)

The signed content is the byte concatenation "{deliveryID}.{unixTimestamp}.{body}", MAC'd with HMAC-SHA256 over the source secret. The signature header value is "v1,<base64(mac)>"; a space-separated list of such tokens is accepted on verify so a sender can rotate secrets without a flag day. Verification recomputes the MAC and uses crypto/hmac.Equal (constant time) against each presented token, after a bidirectional timestamp-tolerance window check (default ±5min).

ref: svix/svix-webhooks go/webhook.go@main ref: stripe/stripe-go webhook/client.go@master

AI-robust funnel: WEBHOOK-HMAC-FUNNEL-01

Enforced by tools/archtest/webhook_hmac_funnel_test.go. Ratings:

  • A1 (downstream Hard): crypto/hmac.New has exactly one callsite (by go/types FullName) — computeMAC. Any other hmac.New in this package fails CI.
  • A2 (downstream Hard): signature comparison may only use crypto/hmac.Equal or crypto/subtle.ConstantTimeCompare; bytes.Equal / == over signature bytes fails CI (constant-time invariant as an AST lock, not a flaky timing test).
  • A3 (upstream Hard external / Medium internal): Signer / Verifier carry an unexported sealed() marker, so external implementations are a compile error (Hard external). Package-internal new holders are NOT blocked by sealing, and A1 does NOT transitively cover them (A1 locks only crypto/hmac.New, not reuse of the package-level computeMAC helper) — that internal axis is covered by A4. True type-system Hard for it is a permanent Go ceiling (same family as #851/#893/#1282/#1375), tracked won't-do at gh #1243.
  • A4 (Medium, internal axis; #1243): every USE of the package-internal computeMAC symbol (direct/parenthesized call or function-value capture) must have an enclosing func ∈ {hmacSigner.Sign, hmacVerifier.Verify} — the actual enforcement of "only the sanctioned signer/verifier may compute a MAC".

Secret-leak defenses (Source.Secret)

pkg/redaction masks the new key set webhook_secret / x_signature / x_hub_signature / x_webhook_signature / svix_signature / hmac_key, but that only covers string-key paths. Source's secret is raw bytes, so a slog.Any("source", src) would otherwise leak it. Two lines of defense: (1) Source.LogValue renders the secret as redaction.Mask (type-system level — every slog of a Source is auto-redacted); (2) archtest blind-spot self-check TestWebhookFunnel_NoRawSecretSlog bans slog of a raw Source / Source secret in this package.

Index

Constants

View Source
const (
	HeaderID        = "webhook-id"
	HeaderTimestamp = "webhook-timestamp"
	HeaderSignature = "webhook-signature"
)

Outbound signature header names. GoCell signs outbound webhooks under the vendor-neutral standard-webhooks header names (webhook-id / webhook-timestamp / webhook-signature) rather than the Svix-branded svix-* names: the dispatcher is the sender, so these headers define GoCell's own outbound protocol and must not embed a third-party vendor name. The signed content, HMAC-SHA256, and "v1,<base64>" token format are Svix / standard-webhooks aligned (see signer.go and ADR webhook-signing-algorithm); only the header names differ.

ref: standard-webhooks/standard-webhooks spec/standard-webhooks.md (webhook-id / webhook-timestamp / webhook-signature header names).

View Source
const MsgSignatureVerificationFailed = "webhook: signature verification failed"

MsgSignatureVerificationFailed is the single, generic wire message returned by every inbound-webhook authentication failure that resolves to HTTP 401 with errcode.ErrWebhookInvalidSignature: an unregistered/unknown source (receiver SourceStore miss) and a non-matching HMAC signature (this verifier) MUST be byte-identical on the wire so the receive path is not a source-ID enumeration oracle (OWASP Authentication Cheat Sheet §Generic Error Messages; WEBHOOK-HMAC-FUNNEL-01 F8/F9). The differentiating reason lives in each caller's WithInternal attribute (server-side slog only, never on the wire).

This is a shared-const convention (Soft): both 401 emit points reference it, but a future third path could still construct a different literal. The narrow exploitability (source IDs are route-bound, not attacker-supplied) makes an archtest disproportionate; the const + this godoc + the errcode.go contract note are the single source of the required wire text.

ref: stripe/stripe-go webhook/client.go@master / svix/svix-webhooks go/webhook.go@main — neither performs a source lookup (secret is caller- supplied), so this oracle surface is GoCell-specific; OWASP provides the governing principle.

Variables

This section is empty.

Functions

func Classify

func Classify(statusCode int, transportErr error) outbox.Disposition

Classify maps an outbound delivery outcome to an outbox.Disposition per the webhook retry-default ADR (standard-webhooks / Svix aligned). Exactly one of the two inputs is meaningful per call: pass the transport error (statusCode ignored) when client.Do failed, or statusCode with a nil error when a response was received. When transportErr is non-nil the statusCode is ignored; pass 0 by convention.

Mapping (standard-webhooks / Svix: the receiver MUST return 2xx to acknowledge; every other outcome is a delivery failure the sender retries):

  • transportErr != nil:
  • SSRF-blocked (ErrWebhookSSRFBlocked — bad target URL, blocked dial, or denied redirect) → Reject (permanent: a misconfigured/hostile target never succeeds on retry).
  • otherwise (DNS resolution failure, timeout, connection refused, …) → Requeue (transient).
  • 2xx → Ack.
  • every non-2xx status (3xx / 4xx / 5xx) → Requeue. A receiver's non-2xx usually reflects a transient condition on its side (deploy gap → 404, secret rotation → 401, throttle → 429, server fault → 5xx); the sender cannot distinguish a "permanent" 4xx from a transient one, so it retries until the budget is exhausted (then ConsumerBase dead-letters). 3xx normally never reaches this branch because redirects are denied at the transport layer and surface as an SSRF transport error (→ Reject above).

Types

type Algorithm

type Algorithm string

Algorithm is the webhook signing algorithm marker. HMAC-SHA256 is the only legal value (see ADR webhook-signing-algorithm); SHA-1 and other downgrades are rejected by Algorithm.Validate.

const AlgorithmHMACSHA256 Algorithm = "hmac-sha256"

AlgorithmHMACSHA256 is the sole supported signing algorithm.

func (Algorithm) String

func (a Algorithm) String() string

String returns the algorithm name as a plain string.

func (Algorithm) Validate

func (a Algorithm) Validate() error

Validate reports whether a is a supported algorithm.

type Delivery

type Delivery struct {
	// DeliveryID is the per-delivery identifier extracted from the configured
	// DeliveryIDHeader and validated before signature verification.
	DeliveryID DeliveryID

	// SourceID is the secret-isolation key that was used to verify this
	// delivery (i.e. the ReceiverSpec.SourceID that matched the inbound request).
	SourceID SourceID

	// Headers are the signature headers that were verified: DeliveryID,
	// Timestamp, and Signature. Re-using webhook.Headers avoids a parallel
	// type; the runtime has already checked all three fields before delivery.
	Headers Headers

	// Payload is the raw request body after signature verification. The slice
	// is owned by the receiver runtime; handlers must not retain it past the
	// handler call.
	Payload []byte
}

Delivery carries the fully-verified context of an inbound webhook request. The receiver runtime populates a Delivery after reading the body, validating the HMAC signature, and claiming the delivery ID for idempotency. Handlers receive a Delivery and must not mutate its Payload slice.

type DeliveryID

type DeliveryID string

DeliveryID is the typed per-delivery identifier. It doubles as the idempotency key on the receiver side and is part of the signed content, so it must not contain whitespace. Construct via NewDeliveryID.

func NewDeliveryID

func NewDeliveryID(s string) (DeliveryID, error)

NewDeliveryID validates s and returns a typed DeliveryID.

func (DeliveryID) String

func (id DeliveryID) String() string

String returns the delivery ID as a plain string.

func (DeliveryID) Validate

func (id DeliveryID) Validate() error

Validate reports whether id is a well-formed delivery ID. A malformed value in an inbound header is a client error, so it maps to errcode.ErrWebhookInvalidHeader.

type DispatchSpec

type DispatchSpec struct {
	ContractID string
	// SourceID is the signing-secret source: it selects the secret used to
	// sign outbound webhook requests sent to the external target. The peer
	// concept is ReceiverSpec.SourceID, which selects the secret for verifying
	// inbound webhook requests.
	SourceID string
	CellID   string
}

DispatchSpec is the pure-data descriptor cellgen emits to register an outbound webhook dispatcher. SourceID names the signing-secret source: it selects the signing secret used when signing outbound requests to the external target (the counterpart of ReceiverSpec.SourceID which selects the secret for verifying inbound requests). The reg.RegisterWebhookDispatch Registrar method and the dispatcher runtime land in PR-5.

func (DispatchSpec) Validate

func (s DispatchSpec) Validate() error

Validate reports an errcode.ErrWebhookConfigInvalid error when any required field is empty. The zero value is invalid.

type Dispatcher

type Dispatcher struct {
	// contains filtered or unexported fields
}

Dispatcher delivers an outbound webhook for each consumed outbox outbox.Entry: it selects the target URL, vets it against the SSRF policy, HMAC-signs the payload, POSTs it, and maps the outcome to an outbox.HandleResult. It implements outbox.EntryHandler via Dispatcher.Handle.

Construct via NewDispatcher; the zero value is invalid. All HTTP egress flows through an *http.Client built from the injected SafePolicy — there is no client-injection option, so SSRF protection cannot be bypassed (WEBHOOK-SSRF-GUARD-01 downstream; see tools/archtest/webhook_ssrf_guard_test.go).

func NewDispatcher

func NewDispatcher(
	clk clock.Clock,
	signer Signer,
	policy *SafePolicy,
	selector WebhookDispatchSelector,
	opts ...DispatcherOption,
) (*Dispatcher, error)

NewDispatcher constructs a Dispatcher. clk is the mandatory positional clock (CLOCK-POSITIONAL-INJECTION-01). signer, policy, and selector are mandatory; a nil argument returns an errcode.ErrWebhookConfigInvalid error. The outbound *http.Client is always built internally from policy — its DialContext (resolve→vet→dial) and DenyRedirect (3xx deny-all) are wired so every request is SSRF-vetted.

func (*Dispatcher) Handle

func (d *Dispatcher) Handle(ctx context.Context, entry outbox.Entry) outbox.HandleResult

Handle implements outbox.EntryHandler: it delivers one outbound webhook and maps the outcome to a disposition via Classify.

Consumer: cg-webhook-dispatch (per-cell consumer group) Idempotency: outbox lease_id CAS (producer side); handler is stateless Disposition: Ack on 2xx / Requeue on every non-2xx + transient transport / Reject on SSRF-blocked + permanent prepare failure DLX: broker-native via DispositionReject -> Nack(requeue=false)

2xx                                    → Ack
non-2xx (3xx/4xx/5xx) / timeout / conn  → Requeue (transient; standard-webhooks aligned)
SSRF-blocked transport / bad config     → Reject  (permanent → DLX)

type DispatcherOption

type DispatcherOption func(*Dispatcher)

DispatcherOption customizes a Dispatcher at construction time.

func WithDeliveryTimeout

func WithDeliveryTimeout(d time.Duration) DispatcherOption

WithDeliveryTimeout overrides the per-attempt delivery timeout (default defaultDeliveryTimeout). A non-positive value is ignored.

func WithMetrics

func WithMetrics(rec Metrics, source string) DispatcherOption

WithMetrics injects the optional delivery observability recorder and the {source} label this dispatcher records under. Omitting it leaves the zero Metrics value, which records as a no-op (the disabled / NopProvider case).

type Headers

type Headers struct {
	DeliveryID DeliveryID
	Timestamp  string
	Signature  string
}

Headers is the INBOUND signature-header DTO that a Verifier consumes. The receiver constructs it from the raw request headers (untrusted input), so its fields are exported by design — a forged Headers simply fails Verifier.Verify. The OUTBOUND, provenance-sealed counterpart written to the wire is SignedHeaders (produced only by Signer.Sign); the two are deliberately distinct types so the wire-write path cannot be fed a hand-built value (#1492).

Timestamp is unix seconds as a decimal string; Signature is one or more space-separated "v1,<base64>" tokens.

type Metrics

type Metrics struct {
	// contains filtered or unexported fields
}

Metrics holds the optional pre-bound webhook instruments. A nil field disables that instrument (every record method nil-checks before recording), so a zero Metrics value — the disabled / NopProvider case — is a safe no-op. Build via RegisterMetrics at the composition root (bootstrap auto-wire) and inject into both the Dispatcher (dispatch side) and the runtime Receiver (receive side); one instance serves both, the instrument sets are disjoint.

The instrument fields are intentionally UNEXPORTED. This seals the upstream of the WEBHOOK-METRIC-LABEL-VALUES-FROZEN-01 funnel: outside kernel/webhook the only metric-write surface is the record* methods below (recordDelivery / observeDeliveryDuration / RecordSignatureFailure / RecordIdempotencyHit), which take the sealed typed label enums — there is no `m.Deliveries.With(Labels{...})` escape hatch for a holder to write an off-set label value. Within the package a new struct cannot be populated except by RegisterMetrics (the sole constructor), and the WEBHOOK-METRIC-LABEL-VALUES-FROZEN-01 callsite guard locks the `.With(...)` calls to the record* method bodies. See tools/archtest/webhook_metric_label_values_frozen_test.go.

func RegisterMetrics

func RegisterMetrics(p kernelmetrics.Provider) (Metrics, error)

RegisterMetrics registers the four webhook instruments on p with canonical names, labels, and buckets, returning them bundled. It is the single source for webhook metric identity; the bootstrap auto-wire calls it once and injects the result into the Dispatcher and the runtime Receiver.

func (Metrics) RecordIdempotencyHit

func (m Metrics) RecordIdempotencyHit(ctx context.Context, source string)

RecordIdempotencyHit increments webhook_idempotency_hits_total{source} when wired. Called by the runtime Receiver when a delivery is a known duplicate (claim already done or in-flight).

func (Metrics) RecordSignatureFailure

func (m Metrics) RecordSignatureFailure(ctx context.Context, source string, reason SignatureFailureReason)

RecordSignatureFailure increments webhook_signature_failures_total{source, reason} when wired. Called by the runtime Receiver after a verification failure with the typed reason its verify step classified.

type ReceiverSpec

type ReceiverSpec struct {
	ContractID string
	// SourceID is the secret-isolation key: it selects the signing secret used
	// to verify the HMAC signature on inbound webhook requests from the external
	// source. Must match contract.yaml endpoints.inbound.sourceID.
	SourceID string
	CellID   string

	// Runtime configuration — cellgen bakes these from contract.yaml fields.
	// PathPattern is the URL path pattern the receiver runtime mounts
	// (contract.yaml endpoints.inbound.pathPattern).
	PathPattern string
	// DeliveryIDHeader is the HTTP header name carrying the per-delivery
	// identifier (contract.yaml signature.deliveryIDHeader).
	DeliveryIDHeader string
	// TimestampHeader is the HTTP header name carrying the unix-seconds
	// timestamp that is part of the signed content
	// (contract.yaml signature.timestampHeader).
	TimestampHeader string
	// SignatureHeader is the HTTP header name carrying the space-separated
	// "v1,<base64>" HMAC tokens (contract.yaml signature.signatureHeader).
	SignatureHeader string
	// ToleranceSeconds is the bidirectional timestamp window in seconds within
	// which a signed delivery is accepted (contract.yaml
	// signature.toleranceSeconds). Must be > 0.
	ToleranceSeconds int64
	// MaxBodyBytes is the maximum accepted request body size in bytes
	// (contract.yaml payload.maxBodyBytes). Must be > 0.
	MaxBodyBytes int64
}

ReceiverSpec is the pure-data descriptor cellgen emits into cell_gen.go to register an inbound webhook receiver. ContractID, SourceID, and CellID are the code-generation-time identifiers (stable since PR-2); PathPattern, DeliveryIDHeader, TimestampHeader, SignatureHeader, ToleranceSeconds, and MaxBodyBytes are the receiver runtime configuration that cellgen bakes from contract.yaml (signature / endpoints.inbound / payload sections) in batch B.

The reg.RegisterWebhookReceiver Registrar method and its bootstrap drain land in PR-3 (receiver runtime). This struct is the cellgen ↔ runtime seam; the dispatch counterpart is DispatchSpec.

func (ReceiverSpec) Validate

func (s ReceiverSpec) Validate() error

Validate reports an errcode.ErrWebhookConfigInvalid error when any required field is empty or out of range. The zero value is invalid.

type RetrySchedule

type RetrySchedule struct {
	// contains filtered or unexported fields
}

RetrySchedule is the fixed sequence of wait intervals between outbound delivery retries. The first delivery attempt is immediate; delays[i] is the wait before retry i+1. The schedule is value-immutable: DefaultSvixSchedule returns a fresh copy and there is no setter.

DefaultSvixSchedule is the canonical default. The per-attempt wall-clock delays ARE honored at runtime (gh #1458): the webhook-dispatch bootstrap drain copies Delays() onto outbox.Subscription.BrokerDelaySchedule, and the Subscriber applies broker-native delayed re-delivery (rabbitmq TTL+DLX delay-tier queues / in-memory clock-timed schedule), durable across restarts. DelayFor is the single-tier lookup; Delays is the bulk seam the wiring consumes.

See docs/architecture/202606012052-1160-adr-webhook-retry-default.md §D5.

func DefaultSvixSchedule

func DefaultSvixSchedule() RetrySchedule

DefaultSvixSchedule returns the Svix default retry schedule: 7 retries after the immediate first attempt (8 deliveries total), spaced 5s / 5min / 30min / 2h / 5h / 10h / 10h.

ref: svix/svix-webhooks docs.svix.com/retries (official retry table) ref: standard-webhooks/standard-webhooks spec/standard-webhooks.md (retry guidance)

func (RetrySchedule) Attempts

func (s RetrySchedule) Attempts() int

Attempts is the total number of delivery attempts: the immediate first delivery plus MaxRetries retries.

func (RetrySchedule) DelayFor

func (s RetrySchedule) DelayFor(n int) (delay time.Duration, ok bool)

DelayFor returns the wait before retry n (1-based: n==1 is the first retry). ok is false when n is out of range (n < 1 or n > MaxRetries), i.e. the retry budget is exhausted.

func (RetrySchedule) Delays

func (s RetrySchedule) Delays() []time.Duration

Delays returns a copy of the retry-delay tiers, in retry order (delays[i] is the wait before retry i+1). It is the bulk-read seam the broker-delay wiring (#1458) consumes to build per-tier delay queues / schedule-driven redelivery; DelayFor remains the single-tier lookup. The slice is cloned so callers cannot mutate the schedule's internal state — call it once at wiring time (e.g. in the dispatch consumer builder), not on the per-delivery hot path.

func (RetrySchedule) MaxRetries

func (s RetrySchedule) MaxRetries() int

MaxRetries is the number of retries after the first (immediate) attempt.

type SafeDialContext

type SafeDialContext func(ctx context.Context, network, address string) (net.Conn, error)

SafeDialContext is the dial function signature shared with net.Dialer.DialContext, so SafePolicy.DialContext drops directly into http.Transport.DialContext (wired by the dispatcher in PR-5).

type SafeOption

type SafeOption func(*SafePolicy)

SafeOption customizes a SafePolicy at construction time.

func WithAllowLoopback

func WithAllowLoopback() SafeOption

WithAllowLoopback permits loopback (127.0.0.0/8, ::1) dial AND pre-flight targets. It is for dev / CI only (e.g. testcontainers) and must never be set in production. It exempts ONLY loopback — every other private/reserved range stays blocked, on both the DialContext and ValidateTargetURL paths.

"Must never be set in production" is enforced by the Medium archtest WEBHOOK-ALLOW-LOOPBACK-PROD-BAN-01 (#1378): any reference in a non-_test.go production file is a CI failure. The stronger Hard form — unexporting this to `withAllowLoopback` so an external reference is a compile error — is tracked at gh #1730 (deferred to preserve the dev escape hatch / future cross-package integration tests).

type SafePolicy

type SafePolicy struct {
	// contains filtered or unexported fields
}

SafePolicy is the single source of webhook outbound SSRF policy. Its three methods — DialContext (resolve→vet→dial-literal), ValidateTargetURL (cheap pre-flight) and DenyRedirect (redirect deny-all) — all share one config, so a policy knob (e.g. WithAllowLoopback) applies identically to the pre-flight and the dial. PR-5 holds one *SafePolicy and wires all three into an *http.Client; there is no divergent per-surface vetting path.

DialContext resolves the host, vets every resolved IP against the SSRF blocklist, then dials a vetted IP literal — closing the DNS-rebinding (TOCTOU) window because the connection target is the already-checked literal, never a re-resolved hostname.

ref: stripe/smokescreen pkg/smokescreen (resolve→vet→dial-literal) ref: stealthrocket/netjail security.go Rules.DialFunc ref: doyensec/safeurl ip.go privateNetworks (CIDR superset; Control mode not used)

func NewSafePolicy

func NewSafePolicy(opts ...SafeOption) *SafePolicy

NewSafePolicy returns a SafePolicy that blocks any address resolving into the SSRF blocklist (see ssrfBlockedCIDRStrings).

func (*SafePolicy) DenyRedirect

func (p *SafePolicy) DenyRedirect(_ *http.Request, via []*http.Request) error

DenyRedirect is an http.Client.CheckRedirect that refuses ALL redirects. A 3xx from a vetted target could otherwise bounce egress to an unvetted (internal) Location; webhook delivery is one-shot, so any redirect is rejected (matching Stripe / GitHub webhook delivery semantics). It is a method so the SSRF policy surface stays coherent, though redirect deny-all needs no config.

func (*SafePolicy) DialContext

func (p *SafePolicy) DialContext(ctx context.Context, network, address string) (net.Conn, error)

DialContext is the SafeDialContext-compatible dial: resolve→vet-all→dial the vetted IP literal. It is the SOLE sanctioned outbound callsite for net.Dialer.DialContext in kernel/webhook (WEBHOOK-SSRF-GUARD-01/A2).

func (*SafePolicy) ValidateTargetURL

func (p *SafePolicy) ValidateTargetURL(rawURL string) error

ValidateTargetURL is a cheap pre-flight: it enforces the {http, https} scheme allowlist, rejects an empty host (fail-closed — an un-vettable target), and rejects a target whose host is an IP literal already blocked by the SSRF policy. The IP-literal check reuses vet, so WithAllowLoopback exempts loopback here exactly as it does at dial time. Hostnames are still vetted at dial time by DialContext (resolving here would create a TOCTOU window).

type SignatureFailureReason

type SignatureFailureReason string

SignatureFailureReason is the exported sealed string type for the webhook_signature_failures_total{reason=...} label. It is exported because the receive-side classification happens in runtime/webhook (the Receiver's verify step), which returns a typed reason that flows into Metrics.RecordSignatureFailure. The value set is enumerable by TYPE; the WEBHOOK-METRIC-LABEL-VALUES-FROZEN-01 archtest freezes it.

The wire 401 is deliberately uniform for ReasonUnknownSource and ReasonBadSignature (anti-enumeration oracle, see the Receiver verify godoc), but the server-side metric distinguishes them — the metric is not a wire oracle, and missing/invalid/unknown/bad/expired enable distinct alerts (the Kubernetes admission error_type / Vault audit reason precedent).

const (
	// ReasonMissingHeader: one or more required signature headers were absent.
	ReasonMissingHeader SignatureFailureReason = "missing_header"
	// ReasonInvalidHeader: a header was present but unparseable (bad delivery
	// id, non-integer timestamp).
	ReasonInvalidHeader SignatureFailureReason = "invalid_header"
	// ReasonUnknownSource: the configured source id was not registered in the
	// source store.
	ReasonUnknownSource SignatureFailureReason = "unknown_source"
	// ReasonBadSignature: the HMAC did not match for any candidate secret.
	ReasonBadSignature SignatureFailureReason = "bad_signature"
	// ReasonTimestampExpired: the timestamp skew exceeded the tolerance window.
	ReasonTimestampExpired SignatureFailureReason = "timestamp_expired"
)

type SignedHeaders

type SignedHeaders struct {
	// contains filtered or unexported fields
}

SignedHeaders is the provenance-sealed OUTBOUND signature-header set: the value Signer.Sign produces and SignedHeaders.Apply writes onto an outbound request. All fields are unexported, INCLUDING a `valid` provenance flag that only Signer.Sign sets. A package-external caller can neither build a populated literal (unexported fields) nor set `valid` on a zero value, so it cannot obtain a valid SignedHeaders; SignedHeaders.Apply fail-closes (writes nothing) on a zero value. Hence the only SignedHeaders that can write to the wire is one produced by the sealed Signer.Sign = WEBHOOK-SIGNER-FUNNEL-01 upstream Hard (external; #1492, valid-token closure of the zero-value-construction gap found in review #1733 F1 — unexported fields alone do NOT stop `var h SignedHeaders`). The in-package `SignedHeaders{valid: true}` literal remains the permanent Go ceiling (same family as #851/#893/#1282/#1375). This is the outbound counterpart to the inbound Headers parse DTO.

func (SignedHeaders) Apply

func (h SignedHeaders) Apply(header http.Header)

Apply writes the three signature headers (HeaderID, HeaderTimestamp, HeaderSignature) onto an outbound request's http.Header.

It is the SOLE sanctioned writer of these header-name constants (WEBHOOK-SIGNER-FUNNEL-01/A1 downstream funnel): the dispatcher MUST call signed.Apply(req.Header) rather than Header.Set a signature header from an arbitrary value, and the archtest locks every Header.Set of these constants to this method body. That makes the *write site* uniform.

Provenance is type-system closed (#1492 + review #1733 F1): Apply fail-closes on a zero-value SignedHeaders (valid==false) — the ONLY form a package-external caller can construct, since the fields (including `valid`) are unexported. Only Signer.Sign sets valid==true, so the value Apply writes always originated from the sealed signer; no external zero-value nor hand-built value can write signature headers to the wire (WEBHOOK-SIGNER-FUNNEL-01/A2 reflect freeze locks the field set incl. `valid`). The in-package `SignedHeaders{valid: true}` literal remains the permanent Go ceiling shared with #851/#893/#1282/#1375.

func (SignedHeaders) DeliveryID

func (h SignedHeaders) DeliveryID() DeliveryID

DeliveryID returns the signed delivery identifier. These accessors expose the values for read-only inspection (logging, test wire reconstruction); the values travel on the wire in plaintext, so reading them is not a secret leak. The seal is on *construction* (only Signer.Sign produces a SignedHeaders), not reading.

func (SignedHeaders) Signature

func (h SignedHeaders) Signature() string

Signature returns the signed "v1,<base64>" signature token(s).

func (SignedHeaders) Timestamp

func (h SignedHeaders) Timestamp() string

Timestamp returns the signed unix-seconds timestamp string.

type Signer

type Signer interface {
	// Sign computes the sealed signature headers for payload at time ts under
	// deliveryID. ts is supplied by the caller (the dispatcher passes its
	// clock's Now) so the signer holds no clock.
	Sign(payload []byte, ts time.Time, deliveryID DeliveryID) (SignedHeaders, error)
	// contains filtered or unexported methods
}

Signer produces a sealed SignedHeaders for an outbound webhook delivery. The interface is sealed (unexported sealed() marker): the sole implementation is the HMAC-SHA256 signer from NewHMACSigner, so package-external types cannot satisfy Signer (WEBHOOK-HMAC-FUNNEL-01/A3 upstream).

func NewHMACSigner

func NewHMACSigner(source Source) (Signer, error)

NewHMACSigner returns an HMAC-SHA256 Signer bound to source. It fails if the source has no secret (a zero-value Source from outside the package).

type Source

type Source struct {
	// contains filtered or unexported fields
}

Source is an opaque webhook source: an identifier plus the shared secret used to sign/verify its deliveries. The secret is an unexported field with no getter — in-package signer/verifier read it directly, package-external callers can only construct a Source via NewSource and never read the secret back. Source.LogValue redacts the secret so slog of a Source is always safe.

func NewSource

func NewSource(id SourceID, secret []byte) (Source, error)

NewSource validates id and secret and returns a Source. The secret must be at least 24 bytes (192-bit floor; above NIST's 128-bit HMAC-SHA256 minimum and compatible with provider keys such as Svix's 24-byte secrets).

func (Source) GoString

func (s Source) GoString() string

GoString implements fmt.GoStringer so fmt.Sprintf("%#v", src) is also safe.

func (Source) ID

func (s Source) ID() SourceID

ID returns the source identifier. The secret is intentionally not exposed.

func (Source) LogValue

func (s Source) LogValue() slog.Value

LogValue implements slog.LogValuer so that logging a Source never leaks the secret: the secret field is rendered as redaction.Mask.

func (Source) String

func (s Source) String() string

String implements fmt.Stringer so fmt.Sprintf("%v", src), fmt.Errorf("… %v", src), and log.Print(src) never leak the secret. fmt invokes String for %v, %s, %q, and %+v — covering every non-#v verb. slog.LogValuer only covers the slog package, so this closes the fmt/log/panic path (WEBHOOK-HMAC-FUNNEL-01 secret-leak defense).

type SourceID

type SourceID string

SourceID is the typed identifier for a webhook source — the secret-isolation key under which a Source is registered. Construct via NewSourceID; the validator mirrors kernel/healthz.ProbeName (snake/kebab lowercase identifier).

func NewSourceID

func NewSourceID(s string) (SourceID, error)

NewSourceID validates s and returns a typed SourceID.

func (SourceID) String

func (id SourceID) String() string

String returns the source ID as a plain string.

func (SourceID) Validate

func (id SourceID) Validate() error

Validate reports whether id is a well-formed source ID.

type SourceRegistry

type SourceRegistry struct {
	// contains filtered or unexported fields
}

SourceRegistry is an in-memory SourceStore. It is safe for concurrent use and is the default for single-process deployments, tests, and demos.

func NewSourceRegistry

func NewSourceRegistry() *SourceRegistry

NewSourceRegistry returns an empty in-memory registry.

func (*SourceRegistry) Lookup

func (r *SourceRegistry) Lookup(id SourceID) (Source, bool)

Lookup implements SourceStore.

func (*SourceRegistry) Register

func (r *SourceRegistry) Register(src Source) error

Register adds or replaces the source registered under src.ID(). It fails with errcode.ErrWebhookConfigInvalid when src has an empty secret — a zero-value Source{} built without NewSource. NewSource always yields a valid Source, so this guard only catches accidental zero-value passing.

type SourceStore

type SourceStore interface {
	// Lookup returns the source registered under id and true, or the zero
	// Source and false when id is unknown.
	Lookup(id SourceID) (Source, bool)
}

SourceStore resolves a SourceID to its registered Source. The receiver looks up the secret for an inbound delivery through this interface, so a persistent / encrypted backing store (configcore, Vault) can replace the in-memory default without touching the verification path.

type Verifier

type Verifier interface {
	// Verify returns nil when rawBody, headers, and source produce a matching
	// HMAC within the tolerance window; otherwise it returns a typed
	// *errcode.Error sentinel (ErrWebhookInvalidHeader / TimestampExpired /
	// InvalidSignature / ConfigInvalid).
	Verify(rawBody []byte, headers Headers, source Source) error
	// contains filtered or unexported methods
}

Verifier checks the signature Headers of an inbound webhook against a Source's secret. The interface is sealed (unexported sealed() marker): the sole implementation is the HMAC-SHA256 verifier from NewHMACVerifier (WEBHOOK-HMAC-FUNNEL-01/A3 upstream).

func NewHMACVerifier

func NewHMACVerifier(clk clock.Clock, opts ...VerifierOption) (Verifier, error)

NewHMACVerifier returns an HMAC-SHA256 Verifier. clk is the mandatory positional clock used for the timestamp-tolerance window (CLOCK-POSITIONAL-INJECTION-01).

type VerifierOption

type VerifierOption func(*hmacVerifier)

VerifierOption configures NewHMACVerifier; use WithTolerance to override defaults.

func WithTolerance

func WithTolerance(d time.Duration) VerifierOption

WithTolerance overrides the default ±5min timestamp window. A non-positive value is rejected at construction.

type WebhookDispatchSelector

type WebhookDispatchSelector func(ctx context.Context, payload []byte) (string, error)

WebhookDispatchSelector is the outbound-webhook target-selector signature cellgen emits as the second argument to reg.RegisterWebhookDispatch. Given an outbound payload it returns the target identifier the dispatcher runtime uses to route and sign the request; a non-nil error signals the dispatcher to skip or fail the dispatch.

PR-2 record-only seam: like WebhookReceiveHandler this is intentionally minimal (stdlib types only). The PR-5 dispatcher runtime MAY refine the signature (e.g. return a structured Target rather than a bare string) — GoCell carries no backward-compat burden (CLAUDE.md), so PR-5 is free to evolve this type alongside the cellgen template.

type WebhookReceiveHandler

type WebhookReceiveHandler func(ctx context.Context, d Delivery) error

WebhookReceiveHandler is the inbound-webhook handler signature cellgen emits as the second argument to reg.RegisterWebhookReceiver. The runtime hands the handler a Delivery that has already been HMAC-verified and idempotency-claimed; a non-nil error signals the receiver runtime to reject (NACK) the delivery.

Handlers MUST be idempotent. The receiver's Claimer is defense-in-depth, not a sole guarantee: webhook delivery is at-least-once by nature (network retries, provider redelivery), and the dedupe window is bounded by the Claimer's done-TTL — a duplicate arriving after that window (e.g. a provider whose retry horizon exceeds the TTL) will re-invoke the handler. This matches the consumer-side guidance of Stripe / Svix, which leave deduplication to the application; GoCell additionally provides the Claimer as a framework-level best-effort layer.

PR-3: signature upgraded from func(ctx, []byte) error to func(ctx, Delivery) error in lockstep with the cellgen template and the generated method values it binds. GoCell carries no backward-compat burden (CLAUDE.md).

Directories

Path Synopsis
Package webhooktest provides a sign↔verify conformance suite for webhook.Signer and webhook.Verifier implementations.
Package webhooktest provides a sign↔verify conformance suite for webhook.Signer and webhook.Verifier implementations.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL