From a6615d11028d097fb8484cd228c18f01a1d90182 Mon Sep 17 00:00:00 2001 From: jonnii Date: 2026年6月22日 22:49:03 -0400 Subject: [PATCH] feat(api): add GitHub webhook signature + push parsing helper Pure, transport-free building blocks for the webhook receiver, kept out of the HTTP handler so they unit-test without a server: - Verify(secret, body, sigHeader) checks the X-Hub-Signature-256 HMAC-SHA256 with a constant-time compare, and fails closed on an empty secret/header so a misconfigured receiver never accepts unsigned payloads. - ParsePush(body) extracts owner/name from a push payload, preferring owner.login + repository.name and falling back to splitting full_name. No routing yet; the receiver wires these in the next change. --- internal/api/githubwebhook/githubwebhook.go | 90 +++++++++++++++ .../api/githubwebhook/githubwebhook_test.go | 103 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 internal/api/githubwebhook/githubwebhook.go create mode 100644 internal/api/githubwebhook/githubwebhook_test.go diff --git a/internal/api/githubwebhook/githubwebhook.go b/internal/api/githubwebhook/githubwebhook.go new file mode 100644 index 00000000..84a5111b --- /dev/null +++ b/internal/api/githubwebhook/githubwebhook.go @@ -0,0 +1,90 @@ +// Package githubwebhook contains the pure, transport-free pieces of the GitHub +// webhook receiver: verifying a delivery's HMAC signature and extracting the +// repository coordinates from a push payload. Keeping these out of the HTTP +// handler lets them be unit-tested without a server and keeps the handler thin. +package githubwebhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" +) + +// SignatureHeader is the request header GitHub signs each delivery with. +const SignatureHeader = "X-Hub-Signature-256" + +// EventHeader names the webhook event type (e.g. "push", "ping"). +const EventHeader = "X-GitHub-Event" + +const signaturePrefix = "sha256=" + +// Verify reports whether sigHeader is a valid HMAC-SHA256 signature of body +// under secret, in GitHub's "sha256=" form. The comparison is +// constant-time. An empty secret or header is treated as invalid so a +// misconfigured receiver fails closed rather than accepting unsigned payloads. +func Verify(secret string, body []byte, sigHeader string) bool { + if secret == "" || sigHeader == "" { + return false + } + if !strings.HasPrefix(sigHeader, signaturePrefix) { + return false + } + want := sigHeader[len(signaturePrefix):] + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + got := hex.EncodeToString(mac.Sum(nil)) + + // hmac.Equal is constant-time over equal-length inputs; the lengths match + // whenever want is a valid sha256 hex digest, and differ harmlessly + // otherwise. + return hmac.Equal([]byte(got), []byte(want)) +} + +// pushPayload captures only the repository coordinates we need from a GitHub +// push event. Owner login and repo name identify the checkout to refresh. +type pushPayload struct { + Repository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Owner struct { + Login string `json:"login"` + Name string `json:"name"` + } `json:"owner"` + } `json:"repository"` +} + +// ParsePush extracts the owner and repository name from a push event body. It +// prefers the explicit owner.login / repository.name fields and falls back to +// splitting full_name ("owner/repo"). ok is false when the body isn't a +// recognizable push payload, so the caller can ignore it without erroring. +func ParsePush(body []byte) (owner, name string, ok bool) { + var p pushPayload + if err := json.Unmarshal(body, &p); err != nil { + return "", "", false + } + + owner = p.Repository.Owner.Login + if owner == "" { + owner = p.Repository.Owner.Name + } + name = p.Repository.Name + + if (owner == "" || name == "") && p.Repository.FullName != "" { + if o, n, found := strings.Cut(p.Repository.FullName, "/"); found { + if owner == "" { + owner = o + } + if name == "" { + name = n + } + } + } + + if owner == "" || name == "" { + return "", "", false + } + return owner, name, true +} diff --git a/internal/api/githubwebhook/githubwebhook_test.go b/internal/api/githubwebhook/githubwebhook_test.go new file mode 100644 index 00000000..348741aa --- /dev/null +++ b/internal/api/githubwebhook/githubwebhook_test.go @@ -0,0 +1,103 @@ +package githubwebhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +// sign produces the "sha256=" header GitHub would send for body+secret. +// It uses the stdlib directly as an independent oracle for Verify. +func sign(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return signaturePrefix + hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerify(t *testing.T) { + t.Parallel() + + secret := "s3cr3t" + body := []byte(`{"zen":"Keep it logically awesome."}`) + valid := sign(secret, body) + + tests := []struct { + name string + secret string + body []byte + header string + want bool + }{ + {name: "valid signature", secret: secret, body: body, header: valid, want: true}, + {name: "wrong secret", secret: "other", body: body, header: valid, want: false}, + {name: "tampered body", secret: secret, body: []byte(`{"zen":"tampered"}`), header: valid, want: false}, + {name: "missing prefix", secret: secret, body: body, header: valid[len(signaturePrefix):], want: false}, + {name: "garbage signature", secret: secret, body: body, header: "sha256=not-hex", want: false}, + {name: "empty secret fails closed", secret: "", body: body, header: valid, want: false}, + {name: "empty header fails closed", secret: secret, body: body, header: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, Verify(tt.secret, tt.body, tt.header)) + }) + } +} + +func TestParsePush(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantOwner string + wantName string + wantOK bool + }{ + { + name: "owner login and repo name", + body: `{"repository":{"name":"widget","full_name":"octo/widget","owner":{"login":"octo"}}}`, + wantOwner: "octo", + wantName: "widget", + wantOK: true, + }, + { + name: "falls back to owner.name when login absent", + body: `{"repository":{"name":"widget","owner":{"name":"octo"}}}`, + wantOwner: "octo", + wantName: "widget", + wantOK: true, + }, + { + name: "falls back to full_name when owner/name absent", + body: `{"repository":{"full_name":"octo/widget","owner":{}}}`, + wantOwner: "octo", + wantName: "widget", + wantOK: true, + }, + { + name: "no repository object", + body: `{"zen":"ping"}`, + wantOK: false, + }, + { + name: "invalid json", + body: `not json`, + wantOK: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + owner, name, ok := ParsePush([]byte(tt.body)) + require.Equal(t, tt.wantOK, ok) + if tt.wantOK { + require.Equal(t, tt.wantOwner, owner) + require.Equal(t, tt.wantName, name) + } + }) + } +}

AltStyle によって変換されたページ (->オリジナル) /