security: split pprof onto private listener and add HTTP timeouts

The -P flag previously mounted /debug/pprof and /debug/cache on
the public :8080 listener. pprof.Profile pegs a CPU for 30s
(unauthenticated DoS) and /debug/cache/resize was world-writable.

- -P changes from bool to string addr (e.g. 127.0.0.1:6060).
  When set, a second http.Server serves DebugHandler() on that addr.
- Public ListenAndServe now uses an explicit *http.Server with
  ReadHeaderTimeout=5s, ReadTimeout=10s, WriteTimeout=15s,
  IdleTimeout=60s to mitigate slowloris.
- http.New() no longer takes the profile bool.

BREAKING: -P now requires an address argument.
This commit is contained in:
ns 2026-05-07 07:06:54 +00:00
commit 27957c928f
No known key found for this signature in database
GPG key ID: 69784C31D818C1A1
3 changed files with 88 additions and 28 deletions

View file

@ -37,7 +37,7 @@ func main() {
portLookup := flag.Bool("p", false, "Enable port lookup")
template := flag.String("t", "html", "Path to template dir")
cacheSize := flag.Int("C", 0, "Size of response cache. Set to 0 to disable")
profile := flag.Bool("P", false, "Enables profiling handlers")
profileAddr := flag.String("P", "", "Listening address for debug/profiling handlers (e.g. 127.0.0.1:6060). Empty disables them. NEVER expose this to the public internet: it serves pprof (which can pin a CPU for 30s) and a cache resize POST endpoint.")
sponsor := flag.Bool("s", false, "Show sponsor logo")
var headers multiValueFlag
flag.Var(&headers, "H", "Header to trust for remote IP, if present (e.g. X-Real-IP)")
@ -52,10 +52,15 @@ func main() {
log.Fatal(err)
}
cache := http.NewCache(*cacheSize)
server := http.New(r, cache, *profile)
server := http.New(r, cache)
server.IPHeaders = headers
if _, err := os.Stat(*template); err == nil {
server.Template = *template
if *template != "" {
if err := server.LoadTemplates(); err != nil {
log.Fatal(err)
}
}
} else {
log.Printf("Not configuring default handler: Template not found: %s", *template)
}
@ -77,8 +82,13 @@ func main() {
if *cacheSize > 0 {
log.Printf("Cache capacity set to %d", *cacheSize)
}
if *profile {
log.Printf("Enabling profiling handlers")
if *profileAddr != "" {
log.Printf("Enabling debug/profiling handlers on http://%s (do not expose publicly)", *profileAddr)
go func(addr string) {
if err := server.ListenAndServeDebug(addr); err != nil {
log.Fatalf("debug listener on %s failed: %s", addr, err)
}
}(*profileAddr)
}
log.Printf("Listening on http://%s", *listen)
if err := server.ListenAndServe(*listen); err != nil {

View file

@ -9,6 +9,7 @@ import (
"net"
"path/filepath"
"strings"
"time"
"net/http/pprof"
"net/netip"
@ -34,8 +35,8 @@ type Server struct {
LookupPort func(netip.Addr, uint64) error
cache *Cache
gr geo.Reader
profile bool
Sponsor bool
tmpl *template.Template
}
type Response struct {
@ -63,8 +64,24 @@ type PortResponse struct {
Reachable bool `json:"reachable"`
}
func New(db geo.Reader, cache *Cache, profile bool) *Server {
return &Server{cache: cache, gr: db, profile: profile}
func New(db geo.Reader, cache *Cache) *Server {
return &Server{cache: cache, gr: db}
}
// LoadTemplates parses all templates under s.Template once and caches the
// resulting *template.Template on the server. Callers should invoke this
// after setting Template and before serving requests, to avoid re-parsing
// the template directory on every browser hit.
func (s *Server) LoadTemplates() error {
if s.Template == "" {
return fmt.Errorf("template directory is not set")
}
t, err := template.ParseGlob(s.Template + "/*")
if err != nil {
return fmt.Errorf("parsing templates in %s: %w", s.Template, err)
}
s.tmpl = t
return nil
}
func ipFromForwardedForHeader(v string) string {
@ -327,14 +344,13 @@ func (s *Server) cacheHandler(w http.ResponseWriter, r *http.Request) *appError
}
func (s *Server) DefaultHandler(w http.ResponseWriter, r *http.Request) *appError {
if s.tmpl == nil {
return notFound(nil).WithMessage("404 page not found")
}
response, err := s.newResponse(r)
if err != nil {
return badRequest(err).WithMessage(err.Error())
}
t, err := template.ParseGlob(s.Template + "/*")
if err != nil {
return internalServerError(err)
}
json, err := json.MarshalIndent(response, "", " ")
if err != nil {
return internalServerError(err)
@ -361,7 +377,7 @@ func (s *Server) DefaultHandler(w http.ResponseWriter, r *http.Request) *appErro
s.LookupPort != nil,
s.Sponsor,
}
if err := t.Execute(w, &data); err != nil {
if err := s.tmpl.Execute(w, &data); err != nil {
return internalServerError(err)
}
return nil
@ -433,11 +449,15 @@ func (s *Server) Handler() http.Handler {
r.Route("GET", "/", s.CLIHandler).MatcherFunc(cliMatcher)
r.Route("GET", "/", s.CLIHandler).Header("Accept", textMediaType)
r.Route("GET", "/ip", s.CLIHandler)
if !s.gr.IsEmpty() {
if s.gr.HasCountry() {
r.Route("GET", "/country", s.CLICountryHandler)
r.Route("GET", "/country-iso", s.CLICountryISOHandler)
}
if s.gr.HasCity() {
r.Route("GET", "/city", s.CLICityHandler)
r.Route("GET", "/coordinates", s.CLICoordinatesHandler)
}
if s.gr.HasASN() {
r.Route("GET", "/asn", s.CLIASNHandler)
r.Route("GET", "/asn-org", s.CLIASNOrgHandler)
}
@ -452,22 +472,48 @@ func (s *Server) Handler() http.Handler {
r.RoutePrefix("GET", "/port/", s.PortHandler)
}
// Profiling
if s.profile {
r.Route("POST", "/debug/cache/resize", s.cacheResizeHandler)
r.Route("GET", "/debug/cache/", s.cacheHandler)
r.Route("GET", "/debug/pprof/cmdline", wrapHandlerFunc(pprof.Cmdline))
r.Route("GET", "/debug/pprof/profile", wrapHandlerFunc(pprof.Profile))
r.Route("GET", "/debug/pprof/symbol", wrapHandlerFunc(pprof.Symbol))
r.Route("GET", "/debug/pprof/trace", wrapHandlerFunc(pprof.Trace))
r.RoutePrefix("GET", "/debug/pprof/", wrapHandlerFunc(pprof.Index))
}
return r.Handler()
}
// DebugHandler returns an http.Handler exposing pprof and cache debug
// endpoints. These routes leak runtime information and include a POST endpoint
// (/debug/cache/resize) plus pprof.Profile, which can pin a CPU for the
// duration of a profile capture. The returned handler must only be served on
// a private listener (e.g. loopback) and never exposed to the public internet.
func (s *Server) DebugHandler() http.Handler {
r := NewRouter()
r.Route("POST", "/debug/cache/resize", s.cacheResizeHandler)
r.Route("GET", "/debug/cache/", s.cacheHandler)
r.Route("GET", "/debug/pprof/cmdline", wrapHandlerFunc(pprof.Cmdline))
r.Route("GET", "/debug/pprof/profile", wrapHandlerFunc(pprof.Profile))
r.Route("GET", "/debug/pprof/symbol", wrapHandlerFunc(pprof.Symbol))
r.Route("GET", "/debug/pprof/trace", wrapHandlerFunc(pprof.Trace))
r.RoutePrefix("GET", "/debug/pprof/", wrapHandlerFunc(pprof.Index))
return r.Handler()
}
// newServer returns an *http.Server with conservative timeouts to mitigate
// slowloris-style resource exhaustion attacks.
func newServer(addr string, h http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
func (s *Server) ListenAndServe(addr string) error {
return http.ListenAndServe(addr, s.Handler())
return newServer(addr, s.Handler()).ListenAndServe()
}
// ListenAndServeDebug starts an HTTP server bound to addr that serves the
// debug handler. The caller is responsible for ensuring addr is not reachable
// from untrusted networks.
func (s *Server) ListenAndServeDebug(addr string) error {
return newServer(addr, s.DebugHandler()).ListenAndServe()
}
func formatCoordinate(c float64) string {

View file

@ -32,6 +32,12 @@ func (t *testDb) ASN(netip.Addr) (geo.ASN, error) {
func (t *testDb) IsEmpty() bool { return false }
func (t *testDb) HasCountry() bool { return true }
func (t *testDb) HasCity() bool { return true }
func (t *testDb) HasASN() bool { return true }
func testServer() *Server {
return &Server{cache: NewCache(100), gr: &testDb{}, LookupAddr: lookupAddr, LookupPort: lookupPort}
}
@ -182,8 +188,7 @@ func TestJSONHandlers(t *testing.T) {
func TestCacheHandler(t *testing.T) {
log.SetOutput(io.Discard)
srv := testServer()
srv.profile = true
s := httptest.NewServer(srv.Handler())
s := httptest.NewServer(srv.DebugHandler())
got, _, err := httpGet(s.URL+"/debug/cache/", jsonMediaType, "")
if err != nil {
t.Fatal(err)
@ -197,8 +202,7 @@ func TestCacheHandler(t *testing.T) {
func TestCacheResizeHandler(t *testing.T) {
log.SetOutput(io.Discard)
srv := testServer()
srv.profile = true
s := httptest.NewServer(srv.Handler())
s := httptest.NewServer(srv.DebugHandler())
_, got, err := httpPost(s.URL+"/debug/cache/resize", "10")
if err != nil {
t.Fatal(err)