mirror of
https://github.com/therootcompany/golib.git
synced 2026-04-24 04:38:02 +00:00
feat(auth/xhubsig): X-Hub-Signature HMAC webhook verification + HTTP middleware
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.
This commit is contained in:
parent
aebef71a95
commit
4abac2a0df
7
auth/xhubsig/LICENSE
Normal file
7
auth/xhubsig/LICENSE
Normal file
@ -0,0 +1,7 @@
|
||||
Authored in 2026 by AJ ONeal <aj@therootcompany.com>
|
||||
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 <https://creativecommons.org/publicdomain/zero/1.0/>.
|
||||
91
auth/xhubsig/README.md
Normal file
91
auth/xhubsig/README.md
Normal file
@ -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=<hex hmac-sha256 of raw request body>` 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.
|
||||
80
auth/xhubsig/errors.go
Normal file
80
auth/xhubsig/errors.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
35
auth/xhubsig/example_test.go
Normal file
35
auth/xhubsig/example_test.go
Normal file
@ -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:
|
||||
// <nil>
|
||||
}
|
||||
|
||||
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)
|
||||
})))
|
||||
}
|
||||
3
auth/xhubsig/go.mod
Normal file
3
auth/xhubsig/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module github.com/therootcompany/golib/auth/xhubsig
|
||||
|
||||
go 1.26.0
|
||||
147
auth/xhubsig/middleware.go
Normal file
147
auth/xhubsig/middleware.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
30
auth/xhubsig/xhubsig.go
Normal file
30
auth/xhubsig/xhubsig.go
Normal file
@ -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
|
||||
}
|
||||
374
auth/xhubsig/xhubsig_test.go
Normal file
374
auth/xhubsig/xhubsig_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user