diff --git a/README.md b/README.md index 91cff3f..b20cd18 100644 --- a/README.md +++ b/README.md @@ -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. @@ -63,7 +63,7 @@ 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 ``` @@ -71,7 +71,7 @@ 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 diff --git a/docs/configuration.md b/docs/configuration.md index b75381d..a48929f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/internal/gateway/admin_test.go b/internal/gateway/admin_test.go index ce76f41..33b027d 100644 --- a/internal/gateway/admin_test.go +++ b/internal/gateway/admin_test.go @@ -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 @@ -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() @@ -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 + `" @@ -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 diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 50b895d..dd49c97 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -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, ` @@ -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 { @@ -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 @@ -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" { @@ -164,7 +164,7 @@ 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 @@ -172,7 +172,7 @@ sources: 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 { @@ -184,7 +184,7 @@ 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 @@ -192,7 +192,7 @@ sources: 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 { diff --git a/internal/verify/fuzz_test.go b/internal/verify/fuzz_test.go index 1e45863..664a2b8 100644 --- a/internal/verify/fuzz_test.go +++ b/internal/verify/fuzz_test.go @@ -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 { @@ -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) } diff --git a/internal/verify/verify.go b/internal/verify/verify.go index b57cec1..4f8ca2b 100644 --- a/internal/verify/verify.go +++ b/internal/verify/verify.go @@ -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: @@ -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") diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go index 134c329..bff465a 100644 --- a/internal/verify/verify_test.go +++ b/internal/verify/verify_test.go @@ -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)) @@ -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 { @@ -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: ","} @@ -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})