mirror of
https://github.com/therootcompany/golib.git
synced 2026-04-24 04:38:02 +00:00
Verify X-Hub-Signature-256 (and SHA-1) webhook signatures. Middleware buffers and re-exposes the body for downstream handlers. Errors honor Accept header: TSV default (text/plain for browsers), JSON, CSV, or Markdown — three fields (error, description, hint) with pseudocode hints.
375 lines
11 KiB
Go
375 lines
11 KiB
Go
package xhubsig
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
var testSecret = "It's a Secret to Everybody"
|
|
var testBody = []byte("Hello, World!")
|
|
|
|
func TestSignSHA256(t *testing.T) {
|
|
sig := Sign(SHA256, testSecret, testBody)
|
|
expected := "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
|
|
if sig != expected {
|
|
t.Errorf("Sign SHA256 = %q, want %q", sig, expected)
|
|
}
|
|
}
|
|
|
|
func TestSignSHA1(t *testing.T) {
|
|
mac := hmac.New(sha1.New, []byte(testSecret))
|
|
mac.Write(testBody)
|
|
want := "sha1=" + hex.EncodeToString(mac.Sum(nil))
|
|
sig := Sign(SHA1, testSecret, testBody)
|
|
if sig != want {
|
|
t.Errorf("Sign SHA1 = %q, want %q", sig, want)
|
|
}
|
|
}
|
|
|
|
func TestVerifySHA256(t *testing.T) {
|
|
sig := Sign(SHA256, testSecret, testBody)
|
|
if err := Verify(SHA256, testSecret, testBody, sig); err != nil {
|
|
t.Errorf("Verify SHA256 should succeed: %v", err)
|
|
}
|
|
if err := Verify(SHA256, testSecret, testBody, "sha256=deadbeef"); !errors.Is(err, ErrInvalidSignature) {
|
|
t.Errorf("Verify SHA256 with wrong sig = %v, want ErrInvalidSignature", err)
|
|
}
|
|
if err := Verify(SHA256, testSecret, testBody, ""); !errors.Is(err, ErrMissingSignature) {
|
|
t.Errorf("Verify SHA256 with empty sig = %v, want ErrMissingSignature", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifySHA1(t *testing.T) {
|
|
sig := Sign(SHA1, testSecret, testBody)
|
|
if err := Verify(SHA1, testSecret, testBody, sig); err != nil {
|
|
t.Errorf("Verify SHA1 should succeed: %v", err)
|
|
}
|
|
if err := Verify(SHA1, "wrong-secret", testBody, sig); !errors.Is(err, ErrInvalidSignature) {
|
|
t.Errorf("Verify SHA1 with wrong secret = %v, want ErrInvalidSignature", err)
|
|
}
|
|
}
|
|
|
|
func TestHashConstants(t *testing.T) {
|
|
if SHA256.Header != "X-Hub-Signature-256" {
|
|
t.Errorf("SHA256.Header = %q, want %q", SHA256.Header, "X-Hub-Signature-256")
|
|
}
|
|
if SHA256.Prefix != "sha256=" {
|
|
t.Errorf("SHA256.Prefix = %q, want %q", SHA256.Prefix, "sha256=")
|
|
}
|
|
if SHA1.Header != "X-Hub-Signature" {
|
|
t.Errorf("SHA1.Header = %q, want %q", SHA1.Header, "X-Hub-Signature")
|
|
}
|
|
if SHA1.Prefix != "sha1=" {
|
|
t.Errorf("SHA1.Prefix = %q, want %q", SHA1.Prefix, "sha1=")
|
|
}
|
|
}
|
|
|
|
func newSignedRequest(t *testing.T, body []byte, hashes ...Hash) *http.Request {
|
|
t.Helper()
|
|
r := httptest.NewRequest("POST", "/", bytes.NewReader(body))
|
|
for _, h := range hashes {
|
|
r.Header.Set(h.Header, Sign(h, testSecret, body))
|
|
}
|
|
return r
|
|
}
|
|
|
|
func TestNewDefaults(t *testing.T) {
|
|
x := New(testSecret)
|
|
if x.Secret != testSecret {
|
|
t.Errorf("Secret = %q, want %q", x.Secret, testSecret)
|
|
}
|
|
if len(x.Hashes) != 1 || x.Hashes[0].Header != SHA256.Header {
|
|
t.Errorf("Hashes = %v, want [SHA256]", x.Hashes)
|
|
}
|
|
if x.AcceptAny {
|
|
t.Error("AcceptAny = true, want false")
|
|
}
|
|
if x.Limit != DefaultLimit {
|
|
t.Errorf("Limit = %d, want %d", x.Limit, DefaultLimit)
|
|
}
|
|
}
|
|
|
|
func TestRequireAllMustPass(t *testing.T) {
|
|
called := false
|
|
x := New(testSecret, SHA1, SHA256)
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("handler failed to read body: %v", err)
|
|
}
|
|
if string(body) != string(testBody) {
|
|
t.Errorf("handler body = %q, want %q", body, testBody)
|
|
}
|
|
called = true
|
|
}))
|
|
|
|
r := newSignedRequest(t, testBody, SHA1, SHA256)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if !called {
|
|
t.Error("handler should have been called when all headers present and valid")
|
|
}
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
func TestRequireAllMissingOneHeader(t *testing.T) {
|
|
x := New(testSecret, SHA1, SHA256)
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("handler should not be called when a header is missing")
|
|
}))
|
|
|
|
r := newSignedRequest(t, testBody, SHA256)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestRequireAllOneHeaderWrong(t *testing.T) {
|
|
x := New(testSecret, SHA1, SHA256)
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("handler should not be called when a signature is wrong")
|
|
}))
|
|
|
|
r := newSignedRequest(t, testBody, SHA1, SHA256)
|
|
r.Header.Set("X-Hub-Signature-256", "sha256=deadbeef")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestRequireAllNoHeaders(t *testing.T) {
|
|
x := New(testSecret, SHA256)
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("handler should not be called without signature")
|
|
}))
|
|
|
|
r := httptest.NewRequest("POST", "/", bytes.NewReader(testBody))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "text/tab-separated-values" {
|
|
t.Errorf("Content-Type = %q, want %q", ct, "text/tab-separated-values")
|
|
}
|
|
if !strings.Contains(w.Body.String(), "X-Hub-Signature-256") {
|
|
t.Errorf("body = %q, want mention of expected header", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRequireAllDefaultsSHA256(t *testing.T) {
|
|
called := false
|
|
x := New(testSecret)
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
}))
|
|
|
|
r := newSignedRequest(t, testBody, SHA256)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if !called {
|
|
t.Error("handler should have been called with default SHA256")
|
|
}
|
|
}
|
|
|
|
func TestRequireAnyAtLeastOne(t *testing.T) {
|
|
called := false
|
|
x := New(testSecret, SHA1, SHA256)
|
|
x.AcceptAny = true
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("handler failed to read body: %v", err)
|
|
}
|
|
if string(body) != string(testBody) {
|
|
t.Errorf("handler body = %q, want %q", body, testBody)
|
|
}
|
|
called = true
|
|
}))
|
|
|
|
r := newSignedRequest(t, testBody, SHA256)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if !called {
|
|
t.Error("handler should be called with only SHA256 header present and valid")
|
|
}
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
func TestRequireAnyBothHeaders(t *testing.T) {
|
|
called := false
|
|
x := New(testSecret, SHA1, SHA256)
|
|
x.AcceptAny = true
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
}))
|
|
|
|
r := newSignedRequest(t, testBody, SHA1, SHA256)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if !called {
|
|
t.Error("handler should have been called with both headers")
|
|
}
|
|
}
|
|
|
|
func TestRequireAnyNoHeaders(t *testing.T) {
|
|
x := New(testSecret, SHA256)
|
|
x.AcceptAny = true
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("handler should not be called without any signature")
|
|
}))
|
|
|
|
r := httptest.NewRequest("POST", "/", bytes.NewReader(testBody))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestRequireAnyPresentButWrong(t *testing.T) {
|
|
x := New(testSecret, SHA1, SHA256)
|
|
x.AcceptAny = true
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("handler should not be called with wrong signature")
|
|
}))
|
|
|
|
r := httptest.NewRequest("POST", "/", bytes.NewReader(testBody))
|
|
r.Header.Set("X-Hub-Signature-256", "sha256=deadbeef")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestRequireAnyPresentButWrongOtherValid(t *testing.T) {
|
|
x := New(testSecret, SHA1, SHA256)
|
|
x.AcceptAny = true
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("handler should not be called when one present header is wrong")
|
|
}))
|
|
|
|
r := newSignedRequest(t, testBody, SHA1)
|
|
r.Header.Set("X-Hub-Signature-256", "sha256=deadbeef")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d — all present sigs must pass", w.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestRequireBodyReadable(t *testing.T) {
|
|
x := New(testSecret, SHA256)
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("failed to read body: %v", err)
|
|
}
|
|
if !strings.Contains(string(body), "Hello") {
|
|
t.Errorf("body = %q, want to contain 'Hello'", body)
|
|
}
|
|
}))
|
|
|
|
r := newSignedRequest(t, testBody, SHA256)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
}
|
|
|
|
func TestNewLimitField(t *testing.T) {
|
|
x := New(testSecret)
|
|
if x.Limit != DefaultLimit {
|
|
t.Errorf("Limit = %d, want %d", x.Limit, DefaultLimit)
|
|
}
|
|
x.Limit = 1 << 20
|
|
if x.Limit != 1<<20 {
|
|
t.Errorf("Limit = %d, want %d", x.Limit, 1<<20)
|
|
}
|
|
}
|
|
|
|
func TestRequireBodyTooLarge(t *testing.T) {
|
|
x := New(testSecret)
|
|
x.Limit = 5
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("handler should not be called when body exceeds limit")
|
|
}))
|
|
|
|
bigBody := make([]byte, 100)
|
|
r := httptest.NewRequest("POST", "/", bytes.NewReader(bigBody))
|
|
r.Header.Set(SHA256.Header, Sign(SHA256, testSecret, bigBody))
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge)
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "text/tab-separated-values" {
|
|
t.Errorf("Content-Type = %q, want %q", ct, "text/tab-separated-values")
|
|
}
|
|
if !strings.Contains(w.Body.String(), "error") {
|
|
t.Errorf("body = %q, want JSON with 'error' key", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestErrorTextPlainForBrowser(t *testing.T) {
|
|
x := New(testSecret)
|
|
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("handler should not be called without signature")
|
|
}))
|
|
|
|
r := httptest.NewRequest("POST", "/", bytes.NewReader(testBody))
|
|
r.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "text/plain" {
|
|
t.Errorf("Content-Type = %q, want %q", ct, "text/plain")
|
|
}
|
|
if !strings.Contains(w.Body.String(), "missing_signature") {
|
|
t.Errorf("body = %q, want TSV with missing_signature", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestVerifyGitHubTestVector(t *testing.T) {
|
|
sig256 := "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
|
|
if err := Verify(SHA256, testSecret, testBody, sig256); err != nil {
|
|
t.Errorf("Verify should pass with GitHub's official test vector: %v", err)
|
|
}
|
|
|
|
mac := hmac.New(sha1.New, []byte(testSecret))
|
|
mac.Write(testBody)
|
|
sig1 := "sha1=" + hex.EncodeToString(mac.Sum(nil))
|
|
if err := Verify(SHA1, testSecret, testBody, sig1); err != nil {
|
|
t.Errorf("Verify should pass with SHA1 test vector: %v", err)
|
|
}
|
|
}
|