Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Data pipelines are built to accept whatever arrives. A webhook endpoint in front
hookgate closes that path:

- **Verifies per sender.** Static tokens and HMAC-SHA1/256/512 signatures in hex, base64 or base64url, with prefixes and multi-signature headers. Every check of a source must pass.
- **Fails closed.** A missing secret takes its own source offline (`503`), never open. Unknown config keys stop startup.
- **Fails closed.** A missing or too-short secret takes its own source offline (`503`), never open. Unknown config keys stop startup.
- **Forwards exactly what it verified.** The upstream receives the verified bytes and an allowlist of headers, nothing else, with retries across instances.
- **Costs almost nothing.** One ~16 MB distroless image, no database, no queue, Prometheus metrics built in.

Expand Down Expand Up @@ -63,15 +63,15 @@ sources:
urls: ["http://echo/"]
EOF

docker run --rm --network hookgate-demo -p 8080:8080 -e GITHUB_WEBHOOK_SECRET=s3cret \
docker run --rm --network hookgate-demo -p 8080:8080 -e GITHUB_WEBHOOK_SECRET=demo-secret-change-me \
-v "$PWD/hookgate.yaml:/etc/hookgate/hookgate.yaml:ro" ghcr.io/mrrobertkent/hookgate
```

Send a signed request and a forged one:

```sh
BODY='{"zen":"Keep it logically awesome."}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac s3cret -hex | awk '{print $NF}')
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac demo-secret-change-me -hex | awk '{print $NF}')

curl -i localhost:8080/github -H "X-Hub-Signature-256: sha256=$SIG" --data-binary "$BODY" # 200, echoed by the backend
curl -i localhost:8080/github -H "X-Hub-Signature-256: sha256=$SIG" --data-binary "${BODY}x" # 401, never forwarded
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ secrets:
optional: true # may be unset or missing
```

Token and HMAC secrets must be at least 16 bytes; use a random value such as `openssl rand -hex 32`. A shorter secret, optional or not, makes its source not ready, and the error names the reference, never the value.

A non-optional secret that is unset, empty or unreadable makes its source **not ready**: the source answers `503` and forwards nothing, the error is logged, and `hookgate_source_ready{source=...}` is `0`. Other sources keep serving. A check whose secrets are all optional and all unset is also not ready, so a source can never run without a secret.

**Rotation:** add the new secret as a second, optional reference, deploy, switch the sender to the new secret, then remove the old reference.
Expand Down
8 changes: 4 additions & 4 deletions internal/gateway/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func TestAdminAndMetrics(t *testing.T) {
up := &captured{}
srv := httptest.NewServer(up.handler(200))
defer srv.Close()
t.Setenv("T", "token-value")
t.Setenv("T", "token-value-0123")
g := newGateway(t, `
sources:
- name: s
Expand All @@ -30,7 +30,7 @@ sources:
checks: [{token: {header: X-T, secrets: [{env: MISSING_FOR_TEST}]}}]
forward: {urls: [`+srv.URL+`]}
`)
post(g, "/s", "{}", map[string]string{"X-T": "token-value"})
post(g, "/s", "{}", map[string]string{"X-T": "token-value-0123"})
post(g, "/s", "{}", map[string]string{"X-T": "wrong"})

admin := g.Admin()
Expand Down Expand Up @@ -79,7 +79,7 @@ func TestRunDrains(t *testing.T) {
up := &captured{}
srv := httptest.NewServer(up.handler(200))
defer srv.Close()
t.Setenv("T", "token-value")
t.Setenv("T", "token-value-0123")
addr, adminAddr := freePort(t), freePort(t)
cfg, err := config.Parse([]byte(`
listen: "` + addr + `"
Expand All @@ -102,7 +102,7 @@ sources:

send := func() (int, error) {
req, _ := http.NewRequest(http.MethodPost, "http://"+addr+"/s", strings.NewReader("{}"))
req.Header.Set("X-T", "token-value")
req.Header.Set("X-T", "token-value-0123")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
Expand Down
16 changes: 8 additions & 8 deletions internal/gateway/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestForwardsOnlyVerifiedBytes(t *testing.T) {
up := &captured{}
srv := httptest.NewServer(up.handler(200))
defer srv.Close()
t.Setenv("E2_TOKEN", "e2-token-value")
t.Setenv("E2_TOKEN", "e2-token-value-0123")
t.Setenv("E2_SECRET", "e2-signing-secret")
t.Setenv("INTERNAL", "internal-bearer")
g := newGateway(t, `
Expand All @@ -79,7 +79,7 @@ sources:
set_headers: {Authorization: {env: INTERNAL}}
`)
body := "[{\"a\":1}]\r\n"
good := map[string]string{"X-E2-Token": "e2-token-value", "X-Sig": sign("e2-signing-secret", body),
good := map[string]string{"X-E2-Token": "e2-token-value-0123", "X-Sig": sign("e2-signing-secret", body),
"Content-Type": "application/json", "X-Keep-Me": "1", "X-Drop-Me": "1", "Authorization": "client-sent"}

if w := post(g, "/e2/events", body, good); w.Code != 200 {
Expand Down Expand Up @@ -132,7 +132,7 @@ func TestSourceWithMissingSecretIsIsolated(t *testing.T) {
up := &captured{}
srv := httptest.NewServer(up.handler(200))
defer srv.Close()
t.Setenv("OK_TOKEN", "ok-token-value")
t.Setenv("OK_TOKEN", "ok-token-value-0123")
g := newGateway(t, `
sources:
- name: broken
Expand All @@ -147,7 +147,7 @@ sources:
if w := post(g, "/broken", "{}", map[string]string{"X-T": ""}); w.Code != 503 {
t.Errorf("broken source: got %d, want 503", w.Code)
}
if w := post(g, "/ok", "{}", map[string]string{"X-T": "ok-token-value"}); w.Code != 200 {
if w := post(g, "/ok", "{}", map[string]string{"X-T": "ok-token-value-0123"}); w.Code != 200 {
t.Errorf("healthy source: got %d, want 200", w.Code)
}
if len(up.reqs) != 1 || up.reqs[0].URL.Path != "/ok" {
Expand All @@ -164,15 +164,15 @@ func TestRetriesNextTargetOn5xxAndTransportError(t *testing.T) {
good := &captured{}
ok := httptest.NewServer(good.handler(202))
defer ok.Close()
t.Setenv("T", "token-value")
t.Setenv("T", "token-value-0123")
g := newGateway(t, `
sources:
- name: s
path: /s
checks: [{token: {header: X-T, secrets: [{env: T}]}}]
forward: {urls: [`+f.URL+`, `+dead.URL+`, `+ok.URL+`], max_attempts: 3}
`)
if w := post(g, "/s", "{}", map[string]string{"X-T": "token-value"}); w.Code != 202 {
if w := post(g, "/s", "{}", map[string]string{"X-T": "token-value-0123"}); w.Code != 202 {
t.Fatalf("got %d, want 202 from the third target", w.Code)
}
if len(failing.reqs) != 1 || len(good.reqs) != 1 {
Expand All @@ -184,15 +184,15 @@ func TestUpstream4xxIsFinal(t *testing.T) {
up := &captured{}
srv := httptest.NewServer(up.handler(401))
defer srv.Close()
t.Setenv("T", "token-value")
t.Setenv("T", "token-value-0123")
g := newGateway(t, `
sources:
- name: s
path: /s
checks: [{token: {header: X-T, secrets: [{env: T}]}}]
forward: {urls: [`+srv.URL+`, `+srv.URL+`]}
`)
if w := post(g, "/s", "{}", map[string]string{"X-T": "token-value"}); w.Code != 401 {
if w := post(g, "/s", "{}", map[string]string{"X-T": "token-value-0123"}); w.Code != 401 {
t.Fatalf("got %d", w.Code)
}
if len(up.reqs) != 1 {
Expand Down
4 changes: 2 additions & 2 deletions internal/verify/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func FuzzHMAC(f *testing.F) {
f.Add([]byte(`{"a":1}`), "AAAA")
f.Add([]byte{}, "")
f.Add([]byte("x"), "v1=,v1=zz")
f.Setenv("FUZZ_KEY", "fuzz-key")
f.Setenv("FUZZ_KEY", "fuzz-signing-key-0123")
v, err := Build(config.Check{HMAC: &config.HMACCheck{Header: "X-Sig", Algorithm: "sha256", Encoding: "base64",
Separator: ",", Secrets: []config.SecretRef{{Env: "FUZZ_KEY"}}}})
if err != nil {
Expand All @@ -26,7 +26,7 @@ func FuzzHMAC(f *testing.F) {
h := http.Header{}
h.Set("X-Sig", sig)
if v.Verify(h, body) == nil {
want := base64.StdEncoding.EncodeToString(mac(sha256.New, "fuzz-key", string(body)))
want := base64.StdEncoding.EncodeToString(mac(sha256.New, "fuzz-signing-key-0123", string(body)))
if !containsSig(sig, want) {
t.Fatalf("accepted forged signature %q for body %q", sig, body)
}
Expand Down
16 changes: 12 additions & 4 deletions internal/verify/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@ import (
"github.com/mrrobertkent/hookgate/internal/config"
)

// MinSecretBytes is the shortest token or HMAC secret accepted. A shorter secret makes its check fail to build,
// so the source stays not ready instead of verifying with a guessable key.
const MinSecretBytes = 16

// Verifier is one configured check with its secrets resolved.
type Verifier interface {
// Verify returns nil when the request passes, or a short reason safe to log (never a secret or a signature).
Verify(h http.Header, body []byte) error
}

// Build resolves the secrets of a check. It fails when no usable secret remains, so a source with a missing
// secret can never verify anything.
// Build resolves the secrets of a check. It fails when a secret is shorter than MinSecretBytes or no usable
// secret remains, so a source with a missing or weak secret can never verify anything.
func Build(c config.Check) (Verifier, error) {
switch {
case c.Token != nil:
Expand Down Expand Up @@ -66,9 +70,13 @@ func resolve(refs []config.SecretRef) ([]string, error) {
if err != nil {
return nil, err
}
if v != "" {
out = append(out, v)
if v == "" {
continue
}
if len(v) < MinSecretBytes {
return nil, fmt.Errorf("%s is shorter than %d bytes", r, MinSecretBytes)
}
out = append(out, v)
}
if len(out) == 0 {
return nil, fmt.Errorf("no secret is set")
Expand Down
42 changes: 35 additions & 7 deletions internal/verify/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import (
"github.com/mrrobertkent/hookgate/internal/config"
)

const (
key1 = "first-signing-key-0123"
key2 = "second-signing-key-456"
)

func mac(h func() hash.Hash, key, body string) []byte {
m := hmac.New(h, []byte(key))
m.Write([]byte(body))
Expand Down Expand Up @@ -79,6 +84,29 @@ func TestMissingSecretFailsClosed(t *testing.T) {
}
}

func TestShortSecretFailsClosed(t *testing.T) {
t.Setenv("SHORT", "fifteen-bytes!!")
t.Setenv("LONG", "sixteen-bytes!!!")
short := []config.SecretRef{{Env: "SHORT"}}
for _, c := range []config.Check{
{Token: &config.TokenCheck{Header: "X-Token", Secrets: short}},
{HMAC: &config.HMACCheck{Header: "X-Sig", Algorithm: "sha256", Encoding: "hex", Secrets: short}},
{HMAC: &config.HMACCheck{Header: "X-Sig", Algorithm: "sha256", Encoding: "hex",
Secrets: []config.SecretRef{{Env: "LONG"}, {Env: "SHORT", Optional: true}}}},
} {
_, err := Build(c)
if err == nil {
t.Fatalf("Build(%+v) accepted a 15-byte secret", c)
}
if !strings.Contains(err.Error(), "env:SHORT") || strings.Contains(err.Error(), "fifteen-bytes") {
t.Errorf("error must name the reference and never the value: %v", err)
}
}
if _, err := Build(config.Check{Token: &config.TokenCheck{Header: "X-Token", Secrets: []config.SecretRef{{Env: "LONG"}}}}); err != nil {
t.Errorf("16-byte secret rejected: %v", err)
}
}

func TestHMAC(t *testing.T) {
body := `[{"eventName":"s3:ObjectCreated:Put"}]` + "\r\n"
type tc struct {
Expand All @@ -88,9 +116,9 @@ func TestHMAC(t *testing.T) {
body string
ok bool
}
b64 := base64.StdEncoding.EncodeToString(mac(sha256.New, "k1", body))
b64next := base64.StdEncoding.EncodeToString(mac(sha256.New, "k2", body))
hx := hex.EncodeToString(mac(sha256.New, "k1", body))
b64 := base64.StdEncoding.EncodeToString(mac(sha256.New, key1, body))
b64next := base64.StdEncoding.EncodeToString(mac(sha256.New, key2, body))
hx := hex.EncodeToString(mac(sha256.New, key1, body))
e2 := config.HMACCheck{Header: "X-Sig", Algorithm: "sha256", Encoding: "base64"}
gh := config.HMACCheck{Header: "X-Sig", Algorithm: "sha256", Encoding: "hex", Prefix: "sha256="}
multi := config.HMACCheck{Header: "X-Sig", Algorithm: "sha256", Encoding: "hex", Prefix: "v1=", Separator: ","}
Expand All @@ -105,12 +133,12 @@ func TestHMAC(t *testing.T) {
{"hex missing prefix", gh, hx, body, false},
{"multi second matches", multi, "v1=00ff,v1=" + hx, body, true},
{"multi none match", multi, "v1=00ff,v0=" + hx, body, false},
{"sha1", config.HMACCheck{Header: "X-Sig", Algorithm: "sha1", Encoding: "hex"}, hex.EncodeToString(mac(sha1.New, "k1", body)), body, true},
{"sha512 base64url", config.HMACCheck{Header: "X-Sig", Algorithm: "sha512", Encoding: "base64url"}, base64.RawURLEncoding.EncodeToString(mac(sha512.New, "k1", body)), body, true},
{"sha1", config.HMACCheck{Header: "X-Sig", Algorithm: "sha1", Encoding: "hex"}, hex.EncodeToString(mac(sha1.New, key1, body)), body, true},
{"sha512 base64url", config.HMACCheck{Header: "X-Sig", Algorithm: "sha512", Encoding: "base64url"}, base64.RawURLEncoding.EncodeToString(mac(sha512.New, key1, body)), body, true},
{"empty signature", e2, "", body, false},
}
t.Setenv("K1", "k1")
t.Setenv("K2", "k2")
t.Setenv("K1", key1)
t.Setenv("K2", key2)
for _, c := range cases {
c.cfg.Secrets = []config.SecretRef{{Env: "K1"}, {Env: "K2", Optional: true}}
v, err := Build(config.Check{HMAC: &c.cfg})
Expand Down
Loading