nomadcode/services/core/internal/http/gito_webhook.go
toki 2b35251b6d fix(G06): restore HTTP payload contract validation in webhook receiver
- Add validateBranchUpdatedPayload() to check body type and changed_files
- Extend gitoWebhookPayload struct with Type and ChangedFiles fields
- Add tests for wrong body type and malformed changed_files rejection
- All tests pass: ./internal/http, ./internal/config, ./cmd/server, ./...
2026-06-19 11:37:00 +09:00

233 lines
No EOL
6.7 KiB
Go

package http
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
stdhttp "net/http"
"strings"
"sync"
)
const maxGitoWebhookBodyBytes = 1 << 20
// GitoBranchUpdatedEvent is the decoded branch.updated payload the HTTP
// receiver passes to the configured handler. It carries only the fields
// needed to route and dedup the event at the HTTP boundary.
type GitoBranchUpdatedEvent struct {
RepoID string
Branch string
Before string
After string
}
// GitoBranchEventHandler is the seam the HTTP receiver forwards a verified
// branch.updated event to. Satisfied by *gitosync.Bridge (via an adapter
// defined in main) and test fakes.
type GitoBranchEventHandler interface {
Handle(ctx context.Context, ev GitoBranchUpdatedEvent) error
}
// gitoDeliveryStore is a process-lifetime idempotency store for HTTP-layer
// delivery-id and revision duplicate detection.
type gitoDeliveryStore struct {
mu sync.Mutex
records map[string]struct{}
}
func newGitoDeliveryStore() *gitoDeliveryStore {
return &gitoDeliveryStore{records: make(map[string]struct{})}
}
func (s *gitoDeliveryStore) hasSeen(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.records[key]
return ok
}
func (s *gitoDeliveryStore) markSeen(key string) {
s.mu.Lock()
defer s.mu.Unlock()
s.records[key] = struct{}{}
}
// gitoWebhookPayload is the minimal JSON structure expected in a Gito
// branch.updated HTTP webhook body. It includes optional fields used for
// contract validation (type, changed_files) without requiring full proto
// socket decoding.
type gitoWebhookPayload struct {
Type string `json:"type"`
RepoID string `json:"repo_id"`
Branch string `json:"branch"`
Before string `json:"before"`
After string `json:"after"`
ChangedFiles interface{} `json:"changed_files"`
}
// validateBranchUpdatedPayload validates that the decoded payload satisfies
// the branch.updated contract. Returns an error description when the payload
// is invalid. This mirrors the validation in
// gitoevents.DecodeBranchUpdatedPayload but works on the plain JSON struct
// to avoid import cycles.
func validateBranchUpdatedPayload(p *gitoWebhookPayload) error {
// body type must be "branch.updated" if present
if p.Type != "" && p.Type != "branch.updated" {
return fmt.Errorf("payload type %q, want %q", p.Type, "branch.updated")
}
// repo_id and branch are required
if p.RepoID == "" {
return errors.New("payload missing required field repo_id")
}
if p.Branch == "" {
return errors.New("payload missing required field branch")
}
// changed_files must be a list of objects if present
if p.ChangedFiles != nil {
_, ok := p.ChangedFiles.([]interface{})
if !ok {
return errors.New("payload changed_files is not a list")
}
}
return nil
}
func (h *Handler) ReceiveGitoWebhook(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !h.gitoConfig.ready() {
writeError(w, stdhttp.StatusServiceUnavailable, "gito webhook secret is not configured")
return
}
body, err := io.ReadAll(stdhttp.MaxBytesReader(w, r.Body, maxGitoWebhookBodyBytes))
if err != nil {
if errors.As(err, new(*stdhttp.MaxBytesError)) {
writeError(w, stdhttp.StatusRequestEntityTooLarge, "webhook body is too large")
return
}
writeError(w, stdhttp.StatusBadRequest, "invalid webhook body")
return
}
signature := strings.TrimSpace(r.Header.Get("X-Gito-Signature"))
if signature == "" || !validGitoWebhookSignature(h.gitoConfig.secret, body, signature) {
writeError(w, stdhttp.StatusUnauthorized, "invalid gito webhook signature")
return
}
event := strings.TrimSpace(r.Header.Get("X-Gito-Event"))
if event != "branch.updated" {
writeError(w, stdhttp.StatusBadRequest, "unexpected gito event type")
return
}
var p gitoWebhookPayload
if err := json.Unmarshal(body, &p); err != nil {
writeError(w, stdhttp.StatusBadRequest, "invalid JSON body")
return
}
if err := validateBranchUpdatedPayload(&p); err != nil {
writeError(w, stdhttp.StatusBadRequest, fmt.Sprintf("invalid branch.updated payload: %s", err.Error()))
return
}
p.RepoID = strings.TrimSpace(p.RepoID)
p.Branch = strings.TrimSpace(p.Branch)
if p.RepoID == "" || p.Branch == "" {
writeError(w, stdhttp.StatusBadRequest, fmt.Sprintf("invalid branch.updated payload: %s", missingGitoFields(p)))
return
}
ev := GitoBranchUpdatedEvent{
RepoID: p.RepoID,
Branch: p.Branch,
Before: strings.TrimSpace(p.Before),
After: strings.TrimSpace(p.After),
}
// Off-target events are acked without side effects.
targetBranch := h.gitoConfig.branch
if targetBranch == "" {
targetBranch = "develop"
}
if ev.RepoID != h.gitoConfig.repoID || ev.Branch != targetBranch {
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "ignored"})
return
}
// HTTP-layer idempotency: check delivery key first (when header present),
// then revision key (when after is set). Mark both after successful handling.
deliveryID := strings.TrimSpace(r.Header.Get("X-Gito-Delivery"))
var deliveryKey, revisionKey string
if deliveryID != "" {
deliveryKey = "delivery:" + deliveryID
if h.gitoDelivery.hasSeen(deliveryKey) {
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "duplicate"})
return
}
}
if ev.After != "" {
revisionKey = "revision:" + ev.RepoID + ":" + ev.Branch + ":" + ev.After
if h.gitoDelivery.hasSeen(revisionKey) {
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "duplicate"})
return
}
}
if h.gitoEventHandler != nil {
if err := h.gitoEventHandler.Handle(r.Context(), ev); err != nil {
// Do not mark keys on error so the caller can retry.
if h.logger != nil {
h.logger.Error("gito webhook handler failed", "error", err)
}
writeError(w, stdhttp.StatusInternalServerError, "internal server error")
return
}
}
if deliveryKey != "" {
h.gitoDelivery.markSeen(deliveryKey)
}
if revisionKey != "" {
h.gitoDelivery.markSeen(revisionKey)
}
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted"})
}
// validGitoWebhookSignature verifies a Gito HMAC-SHA256 webhook signature.
// Accepts both raw hex and the "sha256=<hex>" prefixed form.
func validGitoWebhookSignature(secret string, body []byte, signature string) bool {
sig := signature
if strings.HasPrefix(sig, "sha256=") {
sig = sig[len("sha256="):]
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
expected := mac.Sum(nil)
actual, err := hex.DecodeString(sig)
if err != nil {
return false
}
return hmac.Equal(actual, expected)
}
func missingGitoFields(p gitoWebhookPayload) string {
if p.RepoID == "" && p.Branch == "" {
return "repo_id and branch are required"
}
if p.RepoID == "" {
return "repo_id is required"
}
return "branch is required"
}