All checks were successful
check / check (push) Successful in 3m3s
Capturing real webhook traffic and firing it repeatedly at a backend under development is a primary function of this service, and per-delivery replay cannot do it: it only ever resolves the delivery's own original target, so a target created for a dev backend has no prior delivery and nothing can be replayed to it. The event log now offers a per-event Resubmit action. It stores a NEW event copying the stored one's method, headers, body and content type verbatim, and fans it out to the webhook's currently ACTIVE targets, resolved fresh by the query the receiver uses -- so a target created long after the original event arrived receives it. The original event's deliveries have no bearing on where the copy goes, inactive targets are skipped as the receiver skips them, and the action is repeatable: replay's in-flight refusal is deliberately not ported, because firing one captured event over and over is the point. The receiver and the resubmit path share one construction and one fan-out site. An eventSource value carries where the fields came from, live request or stored event, and createAndFanOut writes the event and its pending deliveries in one transaction and hands the tasks to the same Notifier, so a resubmitted delivery is retried, SSRF-guarded and circuit-broken exactly as a first one is. buildDeliveryTasks returns an error instead of writing a response, which is what lets both callers share it. The stored event is read once, before the write transaction, with a cast to blob, so a body over delivery.MaxInlineBodySize is copied byte for byte and the engine loads it from the new event row. A nullable resubmitted_from_id records provenance -- empty for an event that arrived on the receiver -- and the event log reports the relationship in both directions, without which the log is unreadable after a few resubmits of one event. The route sits in the owned-source group, so auth, CSRF and the body cap apply, with its own rate limit bucket and an events_resubmitted_total counter. Inbound signature verification is not re-run: there is no inbound signature to check on a copy an authenticated, CSRF-protected operator action submits. Per-delivery replay is unchanged; it serves recovery, which resubmit does not replace. The README claimed in four places that replay was unimplemented, one of them telling the operator that a delivery stranded by a target type change was lost; all four are corrected and resubmit is documented beside replay.
476 lines
12 KiB
Go
476 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/logfield"
|
|
"sneak.berlin/go/webhooker/internal/signature"
|
|
)
|
|
|
|
const (
|
|
// maxWebhookBodySize is the maximum allowed webhook
|
|
// request body (1 MB).
|
|
maxWebhookBodySize = 1 << maxBodyShift
|
|
)
|
|
|
|
// HandleWebhook handles incoming webhook requests at entrypoint
|
|
// URLs.
|
|
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(
|
|
w,
|
|
"Method Not Allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
entrypointUUID := chi.URLParam(r, "uuid")
|
|
if entrypointUUID == "" {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
entrypoint, ok := h.lookupEntrypoint(
|
|
w, r, entrypointUUID,
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Logged only once the UUID is known to name a real
|
|
// entrypoint. The UUID comes straight out of the path on
|
|
// the one unauthenticated endpoint, so logging it before
|
|
// the lookup let a client write an INFO line per invented
|
|
// path; the request itself is already in the access log
|
|
// and a miss is already logged at DEBUG.
|
|
h.log.Info("webhook request received",
|
|
"entrypoint_uuid", entrypointUUID,
|
|
"method", r.Method,
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
|
|
if !entrypoint.Active {
|
|
http.Error(w, "Gone", http.StatusGone)
|
|
|
|
return
|
|
}
|
|
|
|
h.processWebhookRequest(w, r, entrypoint)
|
|
}
|
|
}
|
|
|
|
// processWebhookRequest reads the body, verifies the sender,
|
|
// serializes headers, loads targets, and delivers the event.
|
|
func (h *Handlers) processWebhookRequest(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
entrypoint database.Entrypoint,
|
|
) {
|
|
body, ok := h.readWebhookBody(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Before anything is written. An unverified request must leave no
|
|
// event row, no delivery row and no delivery task behind, so this
|
|
// sits above every write rather than inside the transaction that
|
|
// performs them. It has to sit below the body read because the
|
|
// signature is computed over the body; readWebhookBody is what
|
|
// bounds that read, so an unauthenticated sender still cannot make
|
|
// the process hold more than the 1 MB cap.
|
|
if !h.verifyInboundSignature(w, entrypoint, r.Header, body) {
|
|
return
|
|
}
|
|
|
|
// These headers are about to be stored verbatim and handed to
|
|
// every delivery target, so the scheme's credential comes out
|
|
// first. Under GitLab's scheme the header is the shared secret
|
|
// itself, and leaving it in would hand the ability to forge
|
|
// signed requests to exactly the parties the signature is meant
|
|
// to exclude.
|
|
headersJSON, err := json.Marshal(
|
|
signature.SanitizeHeaders(&entrypoint, r.Header),
|
|
)
|
|
if err != nil {
|
|
h.serverError(w, "failed to serialize headers", err)
|
|
|
|
return
|
|
}
|
|
|
|
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
|
if err != nil {
|
|
h.serverError(w, "failed to query targets", err)
|
|
|
|
return
|
|
}
|
|
|
|
h.createAndDeliverEvent(
|
|
w, r, entrypoint, body, headersJSON, targets,
|
|
)
|
|
}
|
|
|
|
// verifyInboundSignature authenticates the request against the
|
|
// entrypoint's configured secret, reporting false once it has written
|
|
// the response.
|
|
//
|
|
// An entrypoint with no secret configured is not checked and this
|
|
// returns true, which is the unchanged behaviour every existing
|
|
// entrypoint keeps.
|
|
//
|
|
// A configuration that cannot be applied — an unknown scheme, or one
|
|
// half of the pair missing — is a 500, not a 401: the request may well
|
|
// be authentic, and calling it unauthorized would tell a legitimate
|
|
// sender to go fix its own signing. Either way it is refused. Failing
|
|
// open here would mean an entrypoint the operator has protected
|
|
// quietly accepting anything.
|
|
func (h *Handlers) verifyInboundSignature(
|
|
w http.ResponseWriter,
|
|
entrypoint database.Entrypoint,
|
|
header http.Header,
|
|
body []byte,
|
|
) bool {
|
|
err := signature.Verify(&entrypoint, header, body)
|
|
if err == nil {
|
|
return true
|
|
}
|
|
|
|
if errors.Is(err, signature.ErrConfig) {
|
|
h.log.Error(
|
|
"entrypoint signature configuration cannot be applied",
|
|
"entrypoint_id", entrypoint.ID,
|
|
"webhook_id", entrypoint.WebhookID,
|
|
"error", err,
|
|
)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
// Every field here is bounded and none is client-chosen: the ids
|
|
// are ours, the scheme is one of a fixed set, and the error is a
|
|
// static string carrying no part of the secret or of what the
|
|
// client presented. Reaching this line also requires a real
|
|
// entrypoint UUID, so it is not a line a stranger can drive.
|
|
h.log.Warn(
|
|
"inbound signature verification failed",
|
|
"entrypoint_id", entrypoint.ID,
|
|
"webhook_id", entrypoint.WebhookID,
|
|
"scheme", string(entrypoint.SignatureScheme),
|
|
"error", err,
|
|
)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
|
|
return false
|
|
}
|
|
|
|
// loadActiveTargets returns all active targets for a webhook.
|
|
func (h *Handlers) loadActiveTargets(
|
|
webhookID string,
|
|
) ([]database.Target, error) {
|
|
var targets []database.Target
|
|
|
|
err := h.db.DB().Where(
|
|
"webhook_id = ? AND active = ?",
|
|
webhookID, true,
|
|
).Find(&targets).Error
|
|
|
|
return targets, err
|
|
}
|
|
|
|
// lookupEntrypoint finds an entrypoint by UUID path.
|
|
func (h *Handlers) lookupEntrypoint(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
entrypointUUID string,
|
|
) (database.Entrypoint, bool) {
|
|
var entrypoint database.Entrypoint
|
|
|
|
result := h.db.DB().Where(
|
|
"path = ?", entrypointUUID,
|
|
).First(&entrypoint)
|
|
if result.Error != nil {
|
|
// The receiver is unauthenticated and /webhook/{uuid}
|
|
// matches any single segment, so this value is entirely
|
|
// client-chosen on exactly the branch where the lookup
|
|
// failed. DEBUG is off by default; the cap is what keeps
|
|
// turning it on from restoring an unbounded write.
|
|
h.log.Debug(
|
|
"entrypoint not found",
|
|
"path", logfield.Truncate(
|
|
entrypointUUID, logfield.MaxBytes,
|
|
),
|
|
)
|
|
http.NotFound(w, r)
|
|
|
|
return entrypoint, false
|
|
}
|
|
|
|
return entrypoint, true
|
|
}
|
|
|
|
// readWebhookBody reads and validates the request body size.
|
|
func (h *Handlers) readWebhookBody(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) ([]byte, bool) {
|
|
body, err := io.ReadAll(
|
|
io.LimitReader(r.Body, maxWebhookBodySize+1),
|
|
)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to read request body", "error", err,
|
|
)
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
if len(body) > maxWebhookBodySize {
|
|
http.Error(
|
|
w,
|
|
"Request body too large",
|
|
http.StatusRequestEntityTooLarge,
|
|
)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
return body, true
|
|
}
|
|
|
|
// createAndDeliverEvent stores the received event, fans it out to the
|
|
// webhook's targets, and answers the sender.
|
|
func (h *Handlers) createAndDeliverEvent(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
entrypoint database.Entrypoint,
|
|
body, headersJSON []byte,
|
|
targets []database.Target,
|
|
) {
|
|
event, tasks, err := h.createAndFanOut(
|
|
requestEventSource(r, entrypoint, headersJSON, body),
|
|
targets,
|
|
)
|
|
if err != nil {
|
|
h.serverError(w, "failed to store webhook event", err)
|
|
|
|
return
|
|
}
|
|
|
|
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
|
}
|
|
|
|
// eventSource carries the fields a new event is built from. The
|
|
// receiver fills it from the live request; the resubmit handler fills
|
|
// it from a stored event. Both then go through createAndFanOut, so an
|
|
// event is constructed and fanned out in one place however it entered
|
|
// the system.
|
|
type eventSource struct {
|
|
WebhookID string
|
|
EntrypointID string
|
|
Method string
|
|
HeadersJSON string
|
|
ContentType string
|
|
Body []byte
|
|
|
|
// ResubmittedFromID names the event this one copies. Only the
|
|
// resubmit path sets it.
|
|
ResubmittedFromID *string
|
|
}
|
|
|
|
// event builds the row this source stores.
|
|
func (s eventSource) event() *database.Event {
|
|
return &database.Event{
|
|
WebhookID: s.WebhookID,
|
|
EntrypointID: s.EntrypointID,
|
|
Method: s.Method,
|
|
Headers: s.HeadersJSON,
|
|
Body: string(s.Body),
|
|
ContentType: s.ContentType,
|
|
ResubmittedFromID: s.ResubmittedFromID,
|
|
}
|
|
}
|
|
|
|
// requestEventSource describes the event a live receiver request
|
|
// stores.
|
|
func requestEventSource(
|
|
r *http.Request,
|
|
entrypoint database.Entrypoint,
|
|
headersJSON, body []byte,
|
|
) eventSource {
|
|
return eventSource{
|
|
WebhookID: entrypoint.WebhookID,
|
|
EntrypointID: entrypoint.ID,
|
|
Method: r.Method,
|
|
HeadersJSON: string(headersJSON),
|
|
ContentType: r.Header.Get("Content-Type"),
|
|
Body: body,
|
|
}
|
|
}
|
|
|
|
// createAndFanOut writes the event and one pending delivery per target
|
|
// in a single transaction, then hands the tasks to the delivery
|
|
// engine. It is the only path by which an event and its deliveries are
|
|
// created, so a resubmitted event is retried, SSRF-guarded and
|
|
// circuit-broken exactly as a received one is.
|
|
//
|
|
// The tasks are returned as well as queued, so a caller can report how
|
|
// many targets the event went to.
|
|
func (h *Handlers) createAndFanOut(
|
|
src eventSource,
|
|
targets []database.Target,
|
|
) (*database.Event, []delivery.Task, error) {
|
|
webhookDB, err := h.dbMgr.GetDB(src.WebhookID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"getting webhook database: %w", err,
|
|
)
|
|
}
|
|
|
|
tx := webhookDB.Begin()
|
|
if tx.Error != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"beginning transaction: %w", tx.Error,
|
|
)
|
|
}
|
|
|
|
event := src.event()
|
|
|
|
err = tx.Create(event).Error
|
|
if err != nil {
|
|
tx.Rollback()
|
|
|
|
return nil, nil, fmt.Errorf("creating event: %w", err)
|
|
}
|
|
|
|
tasks, err := buildDeliveryTasks(
|
|
tx, event, targets, inlineBody(src.Body),
|
|
)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
|
|
return nil, nil, err
|
|
}
|
|
|
|
err = tx.Commit().Error
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"committing transaction: %w", err,
|
|
)
|
|
}
|
|
|
|
// Counted here, after the commit: an event exists once it is
|
|
// durably stored, which is what the delivery counters are
|
|
// compared against on a dashboard. A resubmitted event counts
|
|
// too, because it produces deliveries that the delivery side
|
|
// counts; the resubmit counter is what separates the two.
|
|
h.mtr.EventReceived()
|
|
|
|
if len(tasks) > 0 {
|
|
h.notifier.Notify(tasks)
|
|
}
|
|
|
|
return event, tasks, nil
|
|
}
|
|
|
|
// inlineBody returns a pointer to body as a string if it fits
|
|
// within the inline size limit, or nil otherwise.
|
|
func inlineBody(body []byte) *string {
|
|
if len(body) < delivery.MaxInlineBodySize {
|
|
s := string(body)
|
|
|
|
return &s
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// finishWebhookResponse logs the event and writes the HTTP response.
|
|
func (h *Handlers) finishWebhookResponse(
|
|
w http.ResponseWriter,
|
|
event *database.Event,
|
|
entrypoint database.Entrypoint,
|
|
tasks []delivery.Task,
|
|
) {
|
|
h.log.Info("webhook event created",
|
|
"event_id", event.ID,
|
|
"webhook_id", entrypoint.WebhookID,
|
|
"entrypoint_id", entrypoint.ID,
|
|
"target_count", len(tasks),
|
|
)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
_, err := w.Write([]byte(`{"status":"ok"}`))
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to write response", "error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// buildDeliveryTasks creates one pending delivery per target in the
|
|
// transaction and returns the tasks for the delivery engine. The
|
|
// caller owns the transaction and rolls it back on error.
|
|
func buildDeliveryTasks(
|
|
tx *gorm.DB,
|
|
event *database.Event,
|
|
targets []database.Target,
|
|
bodyPtr *string,
|
|
) ([]delivery.Task, error) {
|
|
tasks := make([]delivery.Task, 0, len(targets))
|
|
|
|
for i := range targets {
|
|
dlv := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targets[i].ID,
|
|
Status: database.DeliveryStatusPending,
|
|
}
|
|
|
|
err := tx.Create(dlv).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"creating delivery for target %s: %w",
|
|
targets[i].ID, err,
|
|
)
|
|
}
|
|
|
|
tasks = append(tasks, delivery.Task{
|
|
DeliveryID: dlv.ID,
|
|
EventID: event.ID,
|
|
WebhookID: event.WebhookID,
|
|
EntrypointID: event.EntrypointID,
|
|
TargetID: targets[i].ID,
|
|
TargetName: targets[i].Name,
|
|
TargetType: targets[i].Type,
|
|
TargetConfig: targets[i].Config,
|
|
MaxRetries: targets[i].MaxRetries,
|
|
Method: event.Method,
|
|
Headers: event.Headers,
|
|
ContentType: event.ContentType,
|
|
Body: bodyPtr,
|
|
AttemptNum: 1,
|
|
})
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|