Skip to content

Commit 1fc5afd

Browse files
committed
Put pprof behind token gate
1 parent b878b82 commit 1fc5afd

6 files changed

Lines changed: 224 additions & 21 deletions

File tree

cmd/cloudinfo/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ type configuration struct {
183183
ServiceLoader loader.Config
184184

185185
Store cistore.Config
186+
187+
Pprof struct {
188+
SecretToken string `mapstructure:"secret_token"`
189+
}
186190
}
187191

188192
// Validate validates the configuration.
@@ -344,6 +348,8 @@ func configure(v *viper.Viper, p *pflag.FlagSet) {
344348

345349
_ = v.BindEnv("provider.digitalocean.accessToken", "DIGITALOCEAN_ACCESS_TOKEN")
346350

351+
_ = v.BindEnv("pprof.secret_token", "CLOUDINFO_PPROF_SECRET_TOKEN")
352+
347353
// Management
348354
v.SetDefault("management.enabled", true)
349355
v.SetDefault("management.address", ":8001")

cmd/cloudinfo/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ func main() {
234234
errorHandler,
235235
)
236236

237-
routeHandler := api.NewRouteHandler(prodInfo, buildInfo, graphqlHandler, cloudInfoLogger)
237+
routeHandler := api.NewRouteHandler(prodInfo, buildInfo, graphqlHandler, cloudInfoLogger, config.Pprof.SecretToken)
238238

239239
// new default gin engine (recovery, logger middleware)
240240
router := gin.Default()

config.toml.dist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ enabled = false
130130
enabled = true
131131
address = ":8001"
132132

133+
[pprof]
134+
# Required in the Token header to access /debug/pprof/*. Empty disables pprof.
135+
secret_token = ""
136+
133137
[serviceloader]
134138
serviceConfigLocation = "./configs"
135139
serviceConfigName = "services"
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright © 2018 Banzai Cloud
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
import (
18+
"crypto/subtle"
19+
"net/http"
20+
"net/http/pprof"
21+
22+
"github.com/gin-gonic/gin"
23+
)
24+
25+
type pprofRoute struct {
26+
pattern string
27+
handler http.Handler
28+
}
29+
30+
func pprofRoutes() []pprofRoute {
31+
return []pprofRoute{
32+
{pattern: "/debug/pprof/", handler: http.HandlerFunc(pprof.Index)},
33+
{pattern: "/debug/pprof/cmdline", handler: http.HandlerFunc(pprof.Cmdline)},
34+
{pattern: "/debug/pprof/profile", handler: http.HandlerFunc(pprof.Profile)},
35+
{pattern: "/debug/pprof/symbol", handler: http.HandlerFunc(pprof.Symbol)},
36+
{pattern: "/debug/pprof/trace", handler: http.HandlerFunc(pprof.Trace)},
37+
{pattern: "/debug/pprof/allocs", handler: pprof.Handler("allocs")},
38+
{pattern: "/debug/pprof/block", handler: pprof.Handler("block")},
39+
{pattern: "/debug/pprof/goroutine", handler: pprof.Handler("goroutine")},
40+
{pattern: "/debug/pprof/heap", handler: pprof.Handler("heap")},
41+
{pattern: "/debug/pprof/mutex", handler: pprof.Handler("mutex")},
42+
{pattern: "/debug/pprof/threadcreate", handler: pprof.Handler("threadcreate")},
43+
}
44+
}
45+
46+
func pprofAuth(secretToken string) gin.HandlerFunc {
47+
return func(c *gin.Context) {
48+
provided := c.GetHeader("Token")
49+
if !tokenEquals(provided, secretToken) {
50+
c.AbortWithStatus(http.StatusForbidden)
51+
return
52+
}
53+
c.Next()
54+
}
55+
}
56+
57+
func attachPprof(router gin.IRouter, secretToken string) {
58+
if secretToken == "" {
59+
return
60+
}
61+
auth := pprofAuth(secretToken)
62+
for _, route := range pprofRoutes() {
63+
router.Any(route.pattern, auth, gin.WrapH(route.handler))
64+
}
65+
}
66+
67+
func tokenEquals(provided, expected string) bool {
68+
if len(provided) != len(expected) {
69+
return false
70+
}
71+
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
72+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright © 2018 Banzai Cloud
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
import (
18+
"net/http"
19+
"net/http/httptest"
20+
"testing"
21+
22+
"github.com/gin-gonic/gin"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
func TestMain(m *testing.M) {
28+
gin.SetMode(gin.TestMode)
29+
m.Run()
30+
}
31+
32+
func TestPprofAuth_RejectsMissingAndInvalidToken(t *testing.T) {
33+
router := gin.New()
34+
router.GET("/debug/pprof/heap", pprofAuth("secret-token"), func(c *gin.Context) {
35+
c.Status(http.StatusOK)
36+
})
37+
38+
t.Run("missing token", func(t *testing.T) {
39+
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil)
40+
rec := httptest.NewRecorder()
41+
router.ServeHTTP(rec, req)
42+
assert.Equal(t, http.StatusForbidden, rec.Code)
43+
})
44+
45+
t.Run("wrong token", func(t *testing.T) {
46+
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil)
47+
req.Header.Set("Token", "not-the-secret")
48+
rec := httptest.NewRecorder()
49+
router.ServeHTTP(rec, req)
50+
assert.Equal(t, http.StatusForbidden, rec.Code)
51+
})
52+
}
53+
54+
func TestPprofAuth_AllowsMatchingToken(t *testing.T) {
55+
router := gin.New()
56+
router.GET("/debug/pprof/heap", pprofAuth("secret-token"), func(c *gin.Context) {
57+
c.String(http.StatusOK, "ok")
58+
})
59+
60+
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil)
61+
req.Header.Set("Token", "secret-token")
62+
rec := httptest.NewRecorder()
63+
router.ServeHTTP(rec, req)
64+
65+
assert.Equal(t, http.StatusOK, rec.Code)
66+
assert.Equal(t, "ok", rec.Body.String())
67+
}
68+
69+
func TestAttachPprof_ProtectsHeapEndpoint(t *testing.T) {
70+
router := gin.New()
71+
attachPprof(router, "secret-token")
72+
73+
t.Run("unauthenticated", func(t *testing.T) {
74+
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil)
75+
rec := httptest.NewRecorder()
76+
router.ServeHTTP(rec, req)
77+
assert.Equal(t, http.StatusForbidden, rec.Code)
78+
})
79+
80+
t.Run("authenticated", func(t *testing.T) {
81+
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil)
82+
req.Header.Set("Token", "secret-token")
83+
rec := httptest.NewRecorder()
84+
router.ServeHTTP(rec, req)
85+
require.Equal(t, http.StatusOK, rec.Code)
86+
assert.NotEmpty(t, rec.Body.Bytes())
87+
})
88+
}
89+
90+
func TestAttachPprof_SkipsHandlersWhenTokenUnset(t *testing.T) {
91+
router := gin.New()
92+
attachPprof(router, "")
93+
94+
for _, route := range router.Routes() {
95+
assert.NotContains(t, route.Path, "/debug/pprof")
96+
}
97+
98+
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil)
99+
req.Header.Set("Token", "anything")
100+
rec := httptest.NewRecorder()
101+
router.ServeHTTP(rec, req)
102+
103+
assert.Equal(t, http.StatusNotFound, rec.Code)
104+
}
105+
106+
func TestPprofRoutes(t *testing.T) {
107+
routes := pprofRoutes()
108+
require.NotEmpty(t, routes)
109+
110+
seen := make(map[string]bool, len(routes))
111+
for _, route := range routes {
112+
assert.NotEmpty(t, route.pattern)
113+
assert.NotNil(t, route.handler)
114+
assert.False(t, seen[route.pattern], "duplicate pprof route %s", route.pattern)
115+
seen[route.pattern] = true
116+
}
117+
118+
assert.True(t, seen["/debug/pprof/heap"])
119+
assert.True(t, seen["/debug/pprof/goroutine"])
120+
}
121+
122+
func TestTokenEquals(t *testing.T) {
123+
assert.True(t, tokenEquals("abc", "abc"))
124+
assert.False(t, tokenEquals("abc", "abd"))
125+
assert.False(t, tokenEquals("abc", "abcd"))
126+
assert.False(t, tokenEquals("", "a"))
127+
}

internal/app/cloudinfo/api/routes.go

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"io/fs"
2020
"io/ioutil"
2121
"net/http"
22-
"net/http/pprof"
2322
"strings"
2423

2524
"emperror.dev/emperror"
@@ -28,7 +27,6 @@ import (
2827
"github.com/gin-contrib/cors"
2928
"github.com/gin-contrib/static"
3029
"github.com/gin-gonic/gin"
31-
"github.com/google/uuid"
3230

3331
"github.com/banzaicloud/cloudinfo/internal/cloudinfo"
3432
"github.com/banzaicloud/cloudinfo/internal/cloudinfo/metrics"
@@ -40,21 +38,23 @@ import (
4038

4139
// RouteHandler configures the REST API routes in the gin router
4240
type RouteHandler struct {
43-
log cloudinfo.Logger
44-
prod types.CloudInfo
45-
buildInfo buildinfo.BuildInfo
46-
errorResponder Responder
47-
graphqlHandler http.Handler
41+
log cloudinfo.Logger
42+
prod types.CloudInfo
43+
buildInfo buildinfo.BuildInfo
44+
errorResponder Responder
45+
graphqlHandler http.Handler
46+
pprofSecretToken string
4847
}
4948

5049
// NewRouteHandler creates a new RouteHandler and returns a reference to it
51-
func NewRouteHandler(p types.CloudInfo, bi buildinfo.BuildInfo, graphqlHandler http.Handler, log cloudinfo.Logger) *RouteHandler {
50+
func NewRouteHandler(p types.CloudInfo, bi buildinfo.BuildInfo, graphqlHandler http.Handler, log cloudinfo.Logger, pprofSecretToken string) *RouteHandler {
5251
return &RouteHandler{
53-
prod: p,
54-
buildInfo: bi,
55-
errorResponder: NewErrorResponder(),
56-
graphqlHandler: graphqlHandler,
57-
log: log,
52+
prod: p,
53+
buildInfo: bi,
54+
errorResponder: NewErrorResponder(),
55+
graphqlHandler: graphqlHandler,
56+
log: log,
57+
pprofSecretToken: pprofSecretToken,
5858
}
5959
}
6060

@@ -124,13 +124,7 @@ func (r *RouteHandler) ConfigureRoutes(router *gin.Engine, basePath string) {
124124

125125
base.POST("/graphql", r.query())
126126

127-
heapRoutePath := fmt.Sprintf("/heap/%s", uuid.New().String())
128-
r.log.Info("Heap pprof path", map[string]interface{}{"path": heapRoutePath})
129-
router.GET(heapRoutePath, gin.WrapF(pprof.Handler("heap").ServeHTTP))
130-
131-
goRoutineRoutePath := fmt.Sprintf("/goroutine/%s", uuid.New().String())
132-
r.log.Info("Goroutine pprof path", map[string]interface{}{"path": goRoutineRoutePath})
133-
router.GET(goRoutineRoutePath, gin.WrapF(pprof.Handler("goroutine").ServeHTTP))
127+
attachPprof(router, r.pprofSecretToken)
134128
}
135129

136130
func (r *RouteHandler) signalStatus(c *gin.Context) {

0 commit comments

Comments
 (0)