From 67e655222767f9737a564a5cba19d3c268e9b2ac Mon Sep 17 00:00:00 2001 From: Simon Martin Date: Wed, 8 Apr 2026 08:03:26 +0000 Subject: [PATCH] server: setup router dedicated to webhook processing This introduces a new chi router to allow processing (in Go) webhook calls coming from other SourceHut services. It ensures that incoming messages are signed with the webhook private key and if so, provides access both to the database and service configuration. Signed-off-by: Simon Martin --- server/server.go | 104 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 24 deletions(-) diff --git a/server/server.go b/server/server.go index a3ae29e0fc57eedb6877bad47f2a0a3146420e56..4bbf350ef80e2e9e949fcd1541ca2e559dc4001c 100644 --- a/server/server.go +++ b/server/server.go @@ -1,12 +1,14 @@ package server import ( + "bytes" "context" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" + "io" "log" "net" "net/http" @@ -66,17 +68,18 @@ var ( type Server struct { Schema graphql.ExecutableSchema - addr []string - metricsAddr string - pprofAddr string - conf ini.File - db *sql.DB - redis goRedis.UniversalClient - root chi.Router - router chi.Router - service string - queues []*work.Queue - email *email.Queue + addr []string + metricsAddr string + pprofAddr string + conf ini.File + db *sql.DB + redis goRedis.UniversalClient + anonRouter chi.Router + webhookRouter chi.Router + router chi.Router + service string + queues []*work.Queue + email *email.Queue MaxComplexity int } @@ -112,29 +115,37 @@ func New(service, defaultAddr string, conf ini.File, args []string) *Server { crypto.InitCrypto(conf) - root := chi.NewRouter() + anonRouter := chi.NewRouter() server := &Server{ - addr: addr, - metricsAddr: metricsAddr, - pprofAddr: pprofAddr, - conf: conf, - root: root, - router: root.Group(func(_ chi.Router) {}), - service: service, + addr: addr, + metricsAddr: metricsAddr, + pprofAddr: pprofAddr, + conf: conf, + anonRouter: anonRouter, + webhookRouter: anonRouter.Group(func(_ chi.Router) {}), + router: anonRouter.Group(func(_ chi.Router) {}), + service: service, } return server } -// Returns the chi Router being used for this sever. All routes on this router +// Returns the chi Router being used for this server. All routes on this router // require authentication. func (server *Server) Router() chi.Router { return server.router } -// Returns the chi Router being used for this sever. All routes on this server +// Returns the chi Router being used for this server. All routes on this server // are unauthenticated. func (server *Server) AnonRouter() chi.Router { - return server.root + return server.anonRouter +} + +// Returns the chi Router being used to process webhooks. All routes on this server +// are unauthenticated but validate that the request is signed with the webhook +// private key. +func (server *Server) WebhookRouter() chi.Router { + return server.webhookRouter } // Adds a GraphQL schema for this server. The second parameter shall be the @@ -183,7 +194,7 @@ func (server *Server) WithSchema( server.router.Handle("/query", srv) // These don't need auth or any other middleware - just log and process - server.root.Group(func(r chi.Router) { + server.anonRouter.Group(func(r chi.Router) { r.Use(middleware.RealIP) if debug { @@ -210,6 +221,51 @@ func (server *Server) WithSchema( w.Write(j) }) }) + + // These validate that the payload is signed with the webhook private key, + // and if so, provide config and database access. + server.webhookRouter = server.anonRouter.Group(func(r chi.Router) { + r.Use(middleware.RealIP) + r.Use(config.Middleware(server.conf, server.service)) + r.Use(database.Middleware(server.db)) + if debug { + r.Use(middleware.Logger) + } + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bodyBytes, _ := io.ReadAll(r.Body) + r.Body.Close() + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + signatures := r.Header["X-Payload-Signature"] + nonces := r.Header["X-Payload-Nonce"] + if len(signatures) > 0 && len(nonces) > 0 { + signature := signatures[0] + nonce := nonces[0] + nonceKey := fmt.Sprintf( + "sr.ht.signature-nonce.%s", + nonce) + _, err := server.redis.Get(r.Context(), + nonceKey).Result() + if err != nil { + // It's the first time we see this nonce; + // remember it and verify the signature. + server.redis.Set( + context.Background(), + nonceKey, "1", + 90*24*time.Hour) + if crypto.VerifyWebhook(bodyBytes, nonce, signature) { + // The signature checks out; keep going. + next.ServeHTTP(w, r) + return + } + } + } + w.WriteHeader(http.StatusForbidden) + }) + }) + }) return server } @@ -374,7 +430,7 @@ func (server *Server) Run() { if err != nil { panic(err) } - qserver := &http.Server{Handler: server.root} + qserver := &http.Server{Handler: server.anonRouter} qservers = append(qservers, qserver) go qserver.Serve(qlisten) }