mirror of
https://github.com/therootcompany/golib.git
synced 2026-04-24 12:48:00 +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