diff --git a/app/controlplane/cmd/main.go b/app/controlplane/cmd/main.go index 14c63be4c..6eac03328 100644 --- a/app/controlplane/cmd/main.go +++ b/app/controlplane/cmd/main.go @@ -18,7 +18,6 @@ package main import ( "context" "math/rand" - _ "net/http/pprof" "os" "time" diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 8f010b46b..8c3626bd6 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -32,10 +32,6 @@ import ( "time" ) -import ( - _ "net/http/pprof" -) - // Injectors from wire.go: func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, logger log.Logger, availablePlugins sdk.AvailablePlugins) (*app, func(), error) { diff --git a/app/controlplane/internal/server/http.go b/app/controlplane/internal/server/http.go index 7e08bd54e..a1be3af4e 100644 --- a/app/controlplane/internal/server/http.go +++ b/app/controlplane/internal/server/http.go @@ -47,6 +47,10 @@ func NewHTTPServer(opts *Opts, grpcSrv *grpc.Server) (*http.Server, error) { var serverOpts = []http.ServerOption{ http.Middleware(middlewares...), } + // Prevent unmatched routes from falling through to http.DefaultServeMux, + // which would otherwise expose net/http/pprof's /debug/pprof/* endpoints + // unauthenticated on the public API listener. + serverOpts = append(serverOpts, hardenedRouteOptions()...) if v := opts.ServerConfig.Http.Network; v != "" { serverOpts = append(serverOpts, http.Network(v)) diff --git a/app/controlplane/internal/server/httpmetrics.go b/app/controlplane/internal/server/httpmetrics.go index 624b0fb3c..a301f8b0f 100644 --- a/app/controlplane/internal/server/httpmetrics.go +++ b/app/controlplane/internal/server/httpmetrics.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -39,6 +39,11 @@ func NewHTTPMetricsServer(opts *Opts) (*HTTPMetricsServer, error) { serverOpts = append(serverOpts, http.Timeout(v.AsDuration())) } + // Prevent unmatched routes from falling through to http.DefaultServeMux, + // which would otherwise expose net/http/pprof's /debug/pprof/* endpoints + // unauthenticated on the metrics listener. + serverOpts = append(serverOpts, hardenedRouteOptions()...) + httpSrv := http.NewServer(serverOpts...) // NOTE: promhttp.Handler() is a singleton that returns the default metrics repository httpSrv.Handle("/metrics", promhttp.Handler()) diff --git a/app/controlplane/internal/server/profiler.go b/app/controlplane/internal/server/profiler.go index 1657ef362..cc2c68e1b 100644 --- a/app/controlplane/internal/server/profiler.go +++ b/app/controlplane/internal/server/profiler.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,19 +16,48 @@ package server import ( + nethttp "net/http" + "net/http/pprof" "time" "github.com/go-kratos/kratos/v2/transport/http" ) -// HTTPMetricsServer is a HTTP server that exposes the metrics endpoint +// HTTPProfilerServer is an opt-in HTTP server that exposes the Go pprof +// endpoints on a dedicated port. It is only started when profiling is +// explicitly enabled via configuration (enable_profiler). type HTTPProfilerServer struct { *http.Server } -// NewHTTPProfilerServer exposes the metrics endpoint in another port +// NewHTTPProfilerServer exposes the pprof endpoints on a dedicated port. +// +// The pprof handlers are registered explicitly on this server's own router, +// instead of relying on the process-wide http.DefaultServeMux. Combined with +// hardenedRouteOptions (which prevents every other kratos server from falling +// back to that default mux), this keeps the unauthenticated /debug/pprof/* +// endpoints off the public API and metrics listeners. func NewHTTPProfilerServer(_ *Opts) (*HTTPProfilerServer, error) { - httpSrv := http.NewServer(http.Address("0.0.0.0:6060"), http.Timeout(10*time.Second)) + serverOpts := append([]http.ServerOption{ + http.Address("0.0.0.0:6060"), + http.Timeout(10 * time.Second), + }, hardenedRouteOptions()...) + + httpSrv := http.NewServer(serverOpts...) + httpSrv.HandlePrefix("/debug/pprof/", pprofMux()) return &HTTPProfilerServer{httpSrv}, nil } + +// pprofMux builds a dedicated ServeMux with the standard pprof routes so they +// are served only from this server and never registered on the process-wide +// http.DefaultServeMux. +func pprofMux() *nethttp.ServeMux { + mux := nethttp.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + return mux +} diff --git a/app/controlplane/internal/server/profiler_test.go b/app/controlplane/internal/server/profiler_test.go new file mode 100644 index 000000000..dbbb5da0b --- /dev/null +++ b/app/controlplane/internal/server/profiler_test.go @@ -0,0 +1,67 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + nethttp "net/http" + "net/http/httptest" + "testing" + + khttp "github.com/go-kratos/kratos/v2/transport/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestProfilerServerServesPprof ensures the dedicated, opt-in profiler server +// exposes the pprof endpoints on its own router (not via the default mux). +func TestProfilerServerServesPprof(t *testing.T) { + srv, err := NewHTTPProfilerServer(&Opts{}) + require.NoError(t, err) + + // Only exercise endpoints that are cheap to serve. /profile and /trace + // block for seconds by design, so they are intentionally left out. + for _, path := range []string{"/debug/pprof/", "/debug/pprof/cmdline", "/debug/pprof/heap"} { + t.Run(path, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(nethttp.MethodGet, path, nil) + srv.ServeHTTP(rec, req) + assert.Equal(t, nethttp.StatusOK, rec.Code) + }) + } +} + +// TestHardenedServerDoesNotExposeDefaultMux ensures a kratos HTTP server built +// with hardenedRouteOptions does not fall through to http.DefaultServeMux, +// where net/http/pprof registers its handlers at init time. This is what keeps +// /debug/pprof/* off the public API and metrics listeners. +func TestHardenedServerDoesNotExposeDefaultMux(t *testing.T) { + // Sanity check: importing net/http/pprof (via profiler.go, same package) + // registers /debug/pprof/* on the process-wide default mux. + sanity := httptest.NewRecorder() + nethttp.DefaultServeMux.ServeHTTP(sanity, httptest.NewRequest(nethttp.MethodGet, "/debug/pprof/", nil)) + require.Equal(t, nethttp.StatusOK, sanity.Code, "expected net/http/pprof to be registered on the default mux") + + opts := append([]khttp.ServerOption{khttp.Address("127.0.0.1:0")}, hardenedRouteOptions()...) + srv := khttp.NewServer(opts...) + + for _, path := range []string{"/debug/pprof/", "/debug/pprof/cmdline", "/debug/vars"} { + t.Run(path, func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest(nethttp.MethodGet, path, nil)) + assert.Equal(t, nethttp.StatusNotFound, rec.Code, "hardened server must not expose debug endpoints via the default mux") + }) + } +} diff --git a/app/controlplane/internal/server/server.go b/app/controlplane/internal/server/server.go index 3948d6562..07827a3e9 100644 --- a/app/controlplane/internal/server/server.go +++ b/app/controlplane/internal/server/server.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,6 +16,9 @@ package server import ( + nethttp "net/http" + + "github.com/go-kratos/kratos/v2/transport/http" "github.com/google/wire" ) @@ -30,3 +33,28 @@ var ProviderSet = wire.NewSet( ) var Version = "dev" + +// hardenedRouteOptions returns kratos HTTP server options that stop the server +// from delegating unmatched routes to http.DefaultServeMux. +// +// By default go-kratos sets every server's NotFoundHandler and +// MethodNotAllowedHandler to http.DefaultServeMux. Anything registered on that +// process-wide mux -- most notably net/http/pprof's /debug/pprof/* handlers, +// which register themselves at init time -- then becomes reachable, without +// authentication, on every kratos HTTP listener (including the public API and +// metrics ports). These options replace that fallthrough with plain 404/405 +// responses so debug endpoints are only ever served where we register them +// explicitly. +func hardenedRouteOptions() []http.ServerOption { + notFound := nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + nethttp.NotFound(w, r) + }) + methodNotAllowed := nethttp.HandlerFunc(func(w nethttp.ResponseWriter, _ *nethttp.Request) { + nethttp.Error(w, nethttp.StatusText(nethttp.StatusMethodNotAllowed), nethttp.StatusMethodNotAllowed) + }) + + return []http.ServerOption{ + http.NotFoundHandler(notFound), + http.MethodNotAllowedHandler(methodNotAllowed), + } +}