Webhook-to-SSE gateway with hierarchical topic routing and signature verification

Make LookupVerification inherit from parent paths

LookupSubscribeSecret already walks up the path hierarchy, but
LookupVerification required an exact match. If you configured
verification at `github.com`, a POST to `github.com/org/repo` would
skip signature checking entirely. Now it uses the same walk-up loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+90 -10
+10 -4
configuration.go
··· 89 89 if c == nil { 90 90 return nil 91 91 } 92 - pc, ok := c.Paths[path] 93 - if !ok || pc.Verify == "" { 94 - return nil 92 + for { 93 + if pc, ok := c.Paths[path]; ok && pc.Verify != "" { 94 + return &pc 95 + } 96 + i := strings.LastIndex(path, "/") 97 + if i < 0 { 98 + break 99 + } 100 + path = path[:i] 95 101 } 96 - return &pc 102 + return nil 97 103 }
+37 -6
configuration_test.go
··· 113 113 } 114 114 } 115 115 116 - func TestLookupPathConfiguration_exactMatchOnly(t *testing.T) { 116 + func TestLookupVerification_inheritsFromParent(t *testing.T) { 117 117 cfg := &Configuration{ 118 118 Paths: map[string]PathConfiguration{ 119 - "github.com/chrisguidry/docketeer": { 119 + "github.com": { 120 120 Verify: "hmac-sha256", 121 121 Secret: "webhook-secret", 122 122 SignatureHeader: "X-Hub-Signature-256", ··· 124 124 }, 125 125 } 126 126 127 - pc := cfg.LookupVerification("github.com/chrisguidry/docketeer") 127 + pc := cfg.LookupVerification("github.com") 128 128 if pc == nil { 129 129 t.Fatal("expected path config for exact match") 130 130 } 131 131 132 - pc = cfg.LookupVerification("github.com/chrisguidry/docketeer/subpath") 133 - if pc != nil { 134 - t.Fatal("verification should not inherit from parent") 132 + pc = cfg.LookupVerification("github.com/org/repo") 133 + if pc == nil { 134 + t.Fatal("expected verification to inherit from parent") 135 + } 136 + if pc.Verify != "hmac-sha256" { 137 + t.Errorf("expected hmac-sha256, got %s", pc.Verify) 138 + } 139 + } 140 + 141 + func TestLookupVerification_childOverridesParent(t *testing.T) { 142 + cfg := &Configuration{ 143 + Paths: map[string]PathConfiguration{ 144 + "github.com": { 145 + Verify: "hmac-sha256", 146 + Secret: "parent-secret", 147 + SignatureHeader: "X-Hub-Signature-256", 148 + }, 149 + "github.com/org/repo": { 150 + Verify: "hmac-sha1", 151 + Secret: "child-secret", 152 + SignatureHeader: "X-Hub-Signature", 153 + }, 154 + }, 155 + } 156 + 157 + pc := cfg.LookupVerification("github.com/org/repo") 158 + if pc == nil { 159 + t.Fatal("expected path config for child") 160 + } 161 + if pc.Secret != "child-secret" { 162 + t.Errorf("expected child-secret, got %s", pc.Secret) 163 + } 164 + if pc.Verify != "hmac-sha1" { 165 + t.Errorf("expected hmac-sha1, got %s", pc.Verify) 135 166 } 136 167 } 137 168
+43
server_test.go
··· 281 281 } 282 282 } 283 283 284 + func TestServer_postInheritsVerificationFromParent(t *testing.T) { 285 + cfg := &Configuration{ 286 + Paths: map[string]PathConfiguration{ 287 + "github.com": { 288 + Verify: "hmac-sha256", 289 + Secret: "parent-secret", 290 + SignatureHeader: "X-Hub-Signature-256", 291 + }, 292 + }, 293 + } 294 + ts, _ := newTestServer(cfg) 295 + defer ts.Close() 296 + 297 + body := `{"action":"push"}` 298 + mac := hmac.New(sha256.New, []byte("parent-secret")) 299 + mac.Write([]byte(body)) 300 + sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) 301 + 302 + req, _ := http.NewRequest("POST", ts.URL+"/github.com/org/repo", strings.NewReader(body)) 303 + req.Header.Set("Content-Type", "application/json") 304 + req.Header.Set("X-Hub-Signature-256", sig) 305 + resp, err := http.DefaultClient.Do(req) 306 + if err != nil { 307 + t.Fatalf("POST failed: %v", err) 308 + } 309 + defer resp.Body.Close() 310 + if resp.StatusCode != http.StatusAccepted { 311 + t.Errorf("expected 202 with valid signature, got %d", resp.StatusCode) 312 + } 313 + 314 + req, _ = http.NewRequest("POST", ts.URL+"/github.com/org/repo", strings.NewReader(body)) 315 + req.Header.Set("Content-Type", "application/json") 316 + req.Header.Set("X-Hub-Signature-256", "sha256=deadbeef") 317 + resp, err = http.DefaultClient.Do(req) 318 + if err != nil { 319 + t.Fatalf("POST failed: %v", err) 320 + } 321 + defer resp.Body.Close() 322 + if resp.StatusCode != http.StatusForbidden { 323 + t.Errorf("expected 403 with invalid signature, got %d", resp.StatusCode) 324 + } 325 + } 326 + 284 327 func TestServer_postWithMissingSignatureOnSecuredPath(t *testing.T) { 285 328 cfg := &Configuration{ 286 329 Paths: map[string]PathConfiguration{