From 4abac2a0df66d2ee2909ddad06e64e32e7754bb7 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 13 Apr 2026 15:13:30 -0600 Subject: [PATCH] feat(auth/xhubsig): X-Hub-Signature HMAC webhook verification + HTTP middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- auth/xhubsig/LICENSE | 7 + auth/xhubsig/README.md | 91 +++++++++ auth/xhubsig/errors.go | 80 ++++++++ auth/xhubsig/example_test.go | 35 ++++ auth/xhubsig/go.mod | 3 + auth/xhubsig/middleware.go | 147 ++++++++++++++ auth/xhubsig/xhubsig.go | 30 +++ auth/xhubsig/xhubsig_test.go | 374 +++++++++++++++++++++++++++++++++++ 8 files changed, 767 insertions(+) create mode 100644 auth/xhubsig/LICENSE create mode 100644 auth/xhubsig/README.md create mode 100644 auth/xhubsig/errors.go create mode 100644 auth/xhubsig/example_test.go create mode 100644 auth/xhubsig/go.mod create mode 100644 auth/xhubsig/middleware.go create mode 100644 auth/xhubsig/xhubsig.go create mode 100644 auth/xhubsig/xhubsig_test.go diff --git a/auth/xhubsig/LICENSE b/auth/xhubsig/LICENSE new file mode 100644 index 0000000..9b558e9 --- /dev/null +++ b/auth/xhubsig/LICENSE @@ -0,0 +1,7 @@ +Authored in 2026 by AJ ONeal +To the extent possible under law, the author(s) have dedicated all copyright +and related and neighboring rights to this software to the public domain +worldwide. This software is distributed without any warranty. + +You should have received a copy of the CC0 Public Domain Dedication along with +this software. If not, see . \ No newline at end of file diff --git a/auth/xhubsig/README.md b/auth/xhubsig/README.md new file mode 100644 index 0000000..6406787 --- /dev/null +++ b/auth/xhubsig/README.md @@ -0,0 +1,91 @@ +# xhubsig + +Verify [X-Hub-Signature-256](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) HMAC-SHA256 webhook signatures. HTTP middleware included. + +```sh +go get github.com/therootcompany/golib/auth/xhubsig +``` + +## Middleware + +Wrap any `http.Handler`. Verified body is buffered and re-readable by the next handler. + +```go +x := xhubsig.New(webhookSecret) +mux.Handle("POST /webhook", x.Require(handleWebhook)) +``` + +Require both SHA-256 and SHA-1 (all must pass): + +```go +x := xhubsig.New(webhookSecret, xhubsig.SHA256, xhubsig.SHA1) +``` + +Accept either SHA-256 or SHA-1 (at least one must be present; all present must pass): + +```go +x := xhubsig.New(webhookSecret, xhubsig.SHA256, xhubsig.SHA1) +x.AcceptAny = true +``` + +Raise the body limit (default 256 KiB): + +```go +x.Limit = 1 << 20 // 1 MiB +``` + +## Sign / Verify + +Compute a signature (for sending or testing): + +```go +sig := xhubsig.Sign(xhubsig.SHA256, secret, body) +// → "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17" +``` + +Verify a signature directly: + +```go +err := xhubsig.Verify(xhubsig.SHA256, secret, body, r.Header.Get("X-Hub-Signature-256")) +if errors.Is(err, xhubsig.ErrMissingSignature) { ... } +if errors.Is(err, xhubsig.ErrInvalidSignature) { ... } +``` + +Signature format: `sha256=` using the webhook secret as the HMAC key. sha256 is the default algorithm. + +## Error responses + +Errors honor the `Accept` header; `Content-Type` matches. Default is TSV. + +| `Accept` | Format | +|---|---| +| `text/tab-separated-values` | vertical key-value TSV *(default)* | +| `text/html` | `text/plain` TSV *(browser-safe)* | +| `application/json` | JSON object | +| `text/csv` | vertical key-value CSV | +| `text/markdown` | pipe table | + +TSV example (`missing_signature`): + +``` +field value +error missing_signature +description No valid signature header was found. +hint X-Hub-Signature-256 is required. `X-Hub-Signature-256: sha256=hex(hmac_sha256(secret, body))` +``` + +JSON example: + +```json +{ + "error": "missing_signature", + "description": "No valid signature header was found.", + "hint": "X-Hub-Signature-256 is required.\n`X-Hub-Signature-256: sha256=hex(hmac_sha256(secret, body))`" +} +``` + +Error codes: `missing_signature`, `invalid_signature`, `body_too_large`. + +## License + +CC0-1.0. Public domain. diff --git a/auth/xhubsig/errors.go b/auth/xhubsig/errors.go new file mode 100644 index 0000000..5dcd152 --- /dev/null +++ b/auth/xhubsig/errors.go @@ -0,0 +1,80 @@ +package xhubsig + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +type httpError struct { + Error string `json:"error"` + Description string `json:"description,omitempty"` + Hint string `json:"hint,omitempty"` +} + +func (e httpError) rows() [][2]string { + rows := [][2]string{{"error", e.Error}} + if e.Description != "" { + rows = append(rows, [2]string{"description", e.Description}) + } + if e.Hint != "" { + rows = append(rows, [2]string{"hint", e.Hint}) + } + return rows +} + +func acceptedFormat(accept string) string { + for part := range strings.SplitSeq(accept, ",") { + mt := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) + switch mt { + case "application/json": + return "json" + case "text/csv": + return "csv" + case "text/markdown": + return "markdown" + case "text/html": + return "text/plain" + } + } + return "tsv" +} + +// writeDelimited writes vertical key-value TSV or CSV. Newlines within +// values are collapsed to a space so agents can split on newlines reliably. +func writeDelimited(w http.ResponseWriter, httpCode int, ct string, sep rune, e httpError) { + w.Header().Set("Content-Type", ct) + w.WriteHeader(httpCode) + cw := csv.NewWriter(w) + cw.Comma = sep + cw.Write([]string{"field", "value"}) + for _, row := range e.rows() { + cw.Write([]string{row[0], strings.ReplaceAll(row[1], "\n", " ")}) + } + cw.Flush() +} + +func serializeError(w http.ResponseWriter, r *http.Request, httpCode int, e httpError) { + switch acceptedFormat(r.Header.Get("Accept")) { + case "tsv": + writeDelimited(w, httpCode, "text/tab-separated-values", '\t', e) + case "text/plain": + writeDelimited(w, httpCode, "text/plain", '\t', e) + case "csv": + writeDelimited(w, httpCode, "text/csv", ',', e) + case "markdown": + w.Header().Set("Content-Type", "text/markdown") + w.WriteHeader(httpCode) + fmt.Fprintln(w, "| field | value |") + fmt.Fprintln(w, "| --- | --- |") + for _, row := range e.rows() { + fmt.Fprintf(w, "| %s | %s |\n", row[0], strings.ReplaceAll(row[1], "\n", " ")) + } + default: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpCode) + json.NewEncoder(w).Encode(e) + } +} diff --git a/auth/xhubsig/example_test.go b/auth/xhubsig/example_test.go new file mode 100644 index 0000000..2197eb3 --- /dev/null +++ b/auth/xhubsig/example_test.go @@ -0,0 +1,35 @@ +package xhubsig_test + +import ( + "fmt" + "net/http" + + "github.com/therootcompany/golib/auth/xhubsig" +) + +func ExampleSign() { + sig := xhubsig.Sign(xhubsig.SHA256, "It's a Secret to Everybody", []byte("Hello, World!")) + fmt.Println(sig) + // Output: + // sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 +} + +func ExampleVerify() { + body := []byte("Hello, World!") + sig := xhubsig.Sign(xhubsig.SHA256, "secret", body) + + err := xhubsig.Verify(xhubsig.SHA256, "secret", body, sig) + fmt.Println(err) + // Output: + // +} + +func ExampleXHubSig_Require() { + x := xhubsig.New("webhookSecret") + + mux := http.NewServeMux() + mux.Handle("POST /webhook", x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // body is verified and re-readable here + w.WriteHeader(http.StatusNoContent) + }))) +} diff --git a/auth/xhubsig/go.mod b/auth/xhubsig/go.mod new file mode 100644 index 0000000..cbae4e1 --- /dev/null +++ b/auth/xhubsig/go.mod @@ -0,0 +1,3 @@ +module github.com/therootcompany/golib/auth/xhubsig + +go 1.26.0 diff --git a/auth/xhubsig/middleware.go b/auth/xhubsig/middleware.go new file mode 100644 index 0000000..a2c1314 --- /dev/null +++ b/auth/xhubsig/middleware.go @@ -0,0 +1,147 @@ +package xhubsig + +import ( + "bytes" + "crypto/sha1" + "crypto/sha256" + "errors" + "fmt" + "hash" + "io" + "net/http" + "strings" +) + +const DefaultLimit = 256 * 1024 + +type Hash struct { + Header string + New func() hash.Hash + Prefix string +} + +var SHA256 = Hash{ + Header: "X-Hub-Signature-256", + New: sha256.New, + Prefix: "sha256=", +} + +var SHA1 = Hash{ + Header: "X-Hub-Signature", + New: sha1.New, + Prefix: "sha1=", +} + +type XHubSig struct { + Secret string + Hashes []Hash + AcceptAny bool + Limit int64 +} + +func New(secret string, hashes ...Hash) *XHubSig { + if len(hashes) == 0 { + hashes = []Hash{SHA256} + } + return &XHubSig{ + Secret: secret, + Hashes: hashes, + AcceptAny: false, + Limit: DefaultLimit, + } +} + +// signatureHint builds a pseudocode hint showing how to compute each +// configured signature header using the webhook secret. +func (x *XHubSig) signatureHint() string { + lines := make([]string, len(x.Hashes)) + for i, h := range x.Hashes { + algo := strings.TrimSuffix(h.Prefix, "=") + lines[i] = fmt.Sprintf("`%s: %shex(hmac_%s(secret, body))`", h.Header, h.Prefix, algo) + } + return strings.Join(lines, "\n") +} + +func (x *XHubSig) writeHTTPError(w http.ResponseWriter, r *http.Request, errCode, detail string) { + var ( + httpCode int + description string + hint string + ) + switch errCode { + case "body_too_large": + httpCode = http.StatusRequestEntityTooLarge + description = "Request body exceeds the maximum allowed size." + hint = detail + "; reduce the payload size." + case "missing_signature": + httpCode = http.StatusUnauthorized + description = "No valid signature header was found." + hint = detail + "\n" + x.signatureHint() + case "invalid_signature": + httpCode = http.StatusUnauthorized + description = "Signature verification failed." + hint = detail + "\n" + x.signatureHint() + default: + httpCode = http.StatusInternalServerError + description = "An unexpected error occurred." + } + serializeError(w, r, httpCode, httpError{ + Error: errCode, + Description: description, + Hint: hint, + }) +} + +func (x *XHubSig) readBody(r *http.Request) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, x.Limit+1)) + r.Body.Close() + if len(body) > int(x.Limit) { + return nil, ErrBodyTooLarge + } + if err != nil { + return nil, err + } + r.Body = io.NopCloser(bytes.NewReader(body)) + return body, nil +} + +func (x *XHubSig) Require(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := x.readBody(r) + if err != nil { + if errors.Is(err, ErrBodyTooLarge) { + x.writeHTTPError(w, r, "body_too_large", fmt.Sprintf("limit is %d bytes", x.Limit)) + return + } + w.WriteHeader(http.StatusBadRequest) // for loggers; client cannot receive a body + return + } + + anyPresent := false + for _, h := range x.Hashes { + sig := r.Header.Get(h.Header) + if sig == "" { + if !x.AcceptAny { + x.writeHTTPError(w, r, "missing_signature", fmt.Sprintf("%s is required", h.Header)) + return + } + continue + } + anyPresent = true + if err := Verify(h, x.Secret, body, sig); err != nil { + x.writeHTTPError(w, r, "invalid_signature", fmt.Sprintf("%s value did not match the expected HMAC", h.Header)) + return + } + } + if !anyPresent { + headers := make([]string, len(x.Hashes)) + for i, h := range x.Hashes { + headers[i] = h.Header + } + x.writeHTTPError(w, r, "missing_signature", "expected one of: "+strings.Join(headers, ", ")) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/auth/xhubsig/xhubsig.go b/auth/xhubsig/xhubsig.go new file mode 100644 index 0000000..7716466 --- /dev/null +++ b/auth/xhubsig/xhubsig.go @@ -0,0 +1,30 @@ +package xhubsig + +import ( + "crypto/hmac" + "encoding/hex" + "errors" +) + +var ( + ErrMissingSignature = errors.New("missing signature") + ErrInvalidSignature = errors.New("invalid signature") + ErrBodyTooLarge = errors.New("body too large") +) + +func Sign(h Hash, secret string, body []byte) string { + mac := hmac.New(h.New, []byte(secret)) + mac.Write(body) + return h.Prefix + hex.EncodeToString(mac.Sum(nil)) +} + +func Verify(h Hash, secret string, body []byte, sig string) error { + if sig == "" { + return ErrMissingSignature + } + expected := Sign(h, secret, body) + if hmac.Equal([]byte(expected), []byte(sig)) { + return nil + } + return ErrInvalidSignature +} diff --git a/auth/xhubsig/xhubsig_test.go b/auth/xhubsig/xhubsig_test.go new file mode 100644 index 0000000..5e4313e --- /dev/null +++ b/auth/xhubsig/xhubsig_test.go @@ -0,0 +1,374 @@ +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) + } +}