webhooks

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 25, 2026 License: AGPL-3.0 Imports: 24 Imported by: 0

Documentation

Overview

Package webhooks receives Git push webhooks from GitHub, GitLab and Gitea and turns them into deploy triggers. Every delivery is authenticated before a single byte of the payload is interpreted (build plan §2, "HMAC from day one"):

  • GitHub and Gitea sign the body with HMAC-SHA256 under a per-app secret; the signature is compared with hmac.Equal.
  • GitLab does not sign; it sends the secret verbatim in X-Gitlab-Token, which is compared in constant time against SHA-256 digests so neither content nor length leaks.
  • An app with no secret configured is never accepted: unsigned webhooks do not exist here.
  • Replay protection keys on the SHA-256 of the signed body, not on the provider's delivery-id header (which the signature does not cover). A replayed push answers 202 {"ignored":"duplicate"} and is audited as denied (D-020).

The package never logs, audits or returns in an error any secret, signature or payload byte (golden rule 3). Logs carry only provider, app name, delivery id and outcome.

Index

Constants

View Source
const (
	DefaultDedupeTTL = 24 * time.Hour
	DefaultDedupeMax = 10_000
)

Deduper defaults used when NewDeduper is given non-positive values.

View Source
const (
	ActionRejected = "webhook.rejected"
	ActionReplayed = "webhook.replayed"
	ActionAccepted = "webhook.accepted"
)

Audit actions emitted by the handler.

View Source
const MaxBody = 1 << 20

MaxBody is the largest accepted request body. Push payloads are a few KiB; 1 MiB leaves room for large commit lists while bounding memory per request.

View Source
const Pattern = "/api/v1/webhooks/{provider}/{app}"

Pattern is the chi route the Handler serves.

Variables

View Source
var (
	// ErrMissingSignature means the provider's signature (or token) header is absent.
	ErrMissingSignature = errors.New("webhooks: missing signature")
	// ErrBadSignature means the signature/token did not verify, or no secret is configured.
	ErrBadSignature = errors.New("webhooks: bad signature")
	// ErrMissingEvent means the provider's event-type header (X-GitHub-Event, …) is absent.
	ErrMissingEvent = errors.New("webhooks: missing event header")
	// ErrIgnoredEvent means the delivery verified but is not a branch push we deploy from.
	ErrIgnoredEvent = errors.New("webhooks: ignored event")
	// ErrInvalidPayload means the body is not the JSON shape the provider documents.
	ErrInvalidPayload = errors.New("webhooks: invalid payload")
	// ErrPayloadTooLarge means the body exceeded MaxBody.
	ErrPayloadTooLarge = errors.New("webhooks: payload too large")
)

Sentinel errors.

View Source
var ErrUnknownApp = errors.New("webhooks: unknown app")

ErrUnknownApp is returned by Handler.Lookup when the app does not exist or has no git source. The handler answers 404 for it. Any other Lookup error is treated as an infrastructure failure (500), so callers must wrap their own not-found conditions with this sentinel.

Functions

func DeliveryID

func DeliveryID(p Provider, headers http.Header, body []byte) string

DeliveryID returns the provider's delivery id (X-GitHub-Delivery, X-Gitea-Delivery, X-Gitlab-Event-UUID) for logs and audit events. When the header is absent or malformed the hex SHA-256 of the body is used instead. The header is not covered by the signature, so the value is informational only: replay protection never keys on it (see Handler.Dedupe).

func IgnoreReason

func IgnoreReason(err error) string

IgnoreReason returns the short reason attached to an ErrIgnoredEvent ("not a push", "tag", "branch deleted", …) or "" when err is not an ignored-event error.

func IsPing

func IsPing(p Provider, headers http.Header) bool

IsPing reports whether the delivery is a provider "ping" (GitHub sends one when a hook is created; Gitea can too). Pings are still signed, so call Verify first.

func ReadBody

func ReadBody(r *http.Request) ([]byte, error)

ReadBody reads at most MaxBody bytes of the request body. Larger bodies yield ErrPayloadTooLarge; the remaining bytes are not consumed.

func Verify

func Verify(p Provider, headers http.Header, body, secret []byte) error

Verify authenticates a delivery. It returns nil only when the provider's event header is present, the signature (or token) header is present and well-formed, the secret is non-empty and the credential matches. The HMAC over the body is always computed before any check so that the time taken does not depend on which check fails; comparisons are constant time.

Errors never contain header values, the secret or the body.

Types

type Deduper

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

Deduper remembers delivery keys it has seen for replay protection. It is bounded (oldest first-seen key evicted) and time-limited (a key is forgotten ttl after it was first seen — a replay does not extend the window). Safe for concurrent use.

func NewDeduper

func NewDeduper(ttl time.Duration, max int) *Deduper

NewDeduper returns a Deduper keeping at most max keys for ttl each.

func (*Deduper) Forget

func (d *Deduper) Forget(key string)

Forget drops key so a later delivery with the same id is accepted again. Used when the trigger that followed a successful verification failed, so the provider's redelivery works.

func (*Deduper) Len

func (d *Deduper) Len() int

Len returns the number of live keys.

func (*Deduper) Seen

func (d *Deduper) Seen(key string) bool

Seen reports whether key was already recorded within the TTL and records it otherwise.

type Event

type Event struct {
	Provider   Provider
	DeliveryID string
	// Ref is the full ref (refs/heads/<branch>); Branch is the part after refs/heads/.
	Ref    string
	Branch string
	// Before and After are the commit SHAs the push moved the branch from and to.
	Before, After string
	// HeadMessage is the head commit message, at most maxMessage bytes, control characters
	// other than newline and tab removed.
	HeadMessage string
	// RepoCloneURL is the HTTP(S) clone URL from the payload; RepoSSHURL the SSH one.
	RepoCloneURL string
	RepoSSHURL   string
	// Pusher is the login of the user who pushed.
	Pusher string
	// Deleted is true when the push deleted the branch (the event is then ignored).
	Deleted bool
}

Event is the provider-agnostic view of a verified branch push.

func Parse

func Parse(p Provider, headers http.Header, body []byte) (Event, error)

Parse interprets a verified delivery as a branch push. Anything that is not a push to a branch — pings, other event types, tag pushes, branch deletions — returns an error that unwraps to ErrIgnoredEvent (see IgnoreReason). Malformed bodies return ErrInvalidPayload. Call Verify first; Parse trusts nothing about the sender.

type Handler

type Handler struct {
	// Lookup returns the app's webhook secret, deploy branch and configured provider.
	// It returns ErrUnknownApp (possibly wrapped) when the app has no git source.
	Lookup func(ctx context.Context, app string) (secret []byte, branch string, provider Provider, err error)
	// Trigger enqueues a deploy for a verified, de-duplicated push. Its error is logged, so it
	// must not carry secret values.
	Trigger func(ctx context.Context, app string, ev Event) error
	// Dedupe rejects replays of an already-processed push. The key is derived from the signed
	// body (see dedupeKey), never from the delivery-id header, which is not covered by the
	// signature. Nil disables replay protection (not recommended).
	Dedupe *Deduper
	Logger *slog.Logger
	Audit  audit.Sink
	// contains filtered or unexported fields
}

Handler serves push webhooks. All fields except Dedupe, Logger and Audit are required.

func (*Handler) Register

func (h *Handler) Register(r chi.Router)

Register mounts the handler on r at Pattern.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler for Pattern. It must be routed through chi so that the {provider} and {app} URL parameters are available.

type Provider

type Provider string

Provider identifies the Git hosting service that sent a webhook.

const (
	ProviderGitHub Provider = "github"
	ProviderGitLab Provider = "gitlab"
	ProviderGitea  Provider = "gitea"
)

Supported providers. The string values are the {provider} URL segment and the git_sources.provider column.

func ParseProvider

func ParseProvider(s string) (Provider, error)

ParseProvider maps a URL segment to a Provider.

func (Provider) String

func (p Provider) String() string

String implements fmt.Stringer.

Jump to

Keyboard shortcuts

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