Arc: Unauthenticated access to Go debug pprof endpoints leaks runtime state and enables CPU-burn DoS
Description
Unauthenticated access to multiple /debug/pprof endpoints in Arc < v26.06.1 leaks sensitive runtime data and allows CPU-burn denial of service.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Unauthenticated access to multiple /debug/pprof endpoints in Arc < v26.06.1 leaks sensitive runtime data and allows CPU-burn denial of service.
Vulnerability
In versions of Arc prior to v26.06.1 (commit 32a4091 [1]), the net/http/pprof handlers are registered at /debug/pprof/* via app.Use(pprof.New()) in internal/api/server.go. Additionally, /debug/pprof is added to the PublicPrefixes list in cmd/arc/main.go. Because the auth middleware short-circuits before token verification when the request path matches a public prefix, all pprof endpoints are reachable without any authentication [3][4].
Exploitation
An attacker with network access to the Arc API port can send requests to any /debug/pprof/ endpoint without needing a token. No authentication, rate limiting, or resource bounds exist on the seconds parameter for profile endpoints [3][4]. The attacker can fetch /debug/pprof/heap to dump in-memory state, /debug/pprof/goroutine?debug=2 to obtain call stacks, /debug/pprof/profile?seconds=N to consume CPU for a long period (DoS), and /debug/pprof/trace for long-duration execution tracing [3][4].
Impact
Successful exploitation leads to information disclosure: heap profiles leak live SQL strings, decoded msgpack records, decompressed request bodies, and cached *TokenInfo objects (the auth cache keys on SHA-256 of the plaintext token at auth.go:543). Goroutine dumps reveal internal code paths. Furthermore, CPU-burn denial of service is trivial: a single HTTP request can pin a CPU core for minutes, and multiple requests can exhaust server resources [3][4].
Mitigation
The vulnerability is fixed in Arc v26.06.1 [2]. The fix [1] gates pprof registration behind an environment variable ARC_DEBUG_PPROF that defaults to off. When enabled, pprof binds to a loopback-only listener (127.0.0.1:6060) via a dedicated net/http server, removes /debug/pprof from PublicPrefixes, and corrects a HasPrefix bug that matched paths like /debug/pprofX. Workarounds include blocking /debug/pprof* at a reverse proxy, restricting the API port with firewall rules, or commenting out app.Use(pprof.New()) in internal/api/server.go and rebuilding [4].
AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: < 0.0.0-20260520170331-32a4091fb949
Patches
232a4091fb949fix(security): gate pprof behind ARC_DEBUG_PPROF on a localhost listener + anchor + normalise PublicPrefixes match (#443)
8 files changed · +725 −9
cmd/arc/debug_pprof.go+212 −0 added@@ -0,0 +1,212 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/http/pprof" + "os" + "strings" + "time" + + "github.com/basekick-labs/arc/internal/shutdown" + "github.com/rs/zerolog" +) + +// debugPprofEnvVar is the env-var operators set to opt into the localhost +// pprof listener. Any value matching the isTruthy contract turns it on; +// everything else (including unset) leaves it off. +const debugPprofEnvVar = "ARC_DEBUG_PPROF" + +// debugPprofAddrEnvVar lets operators override the bind address. Default is +// "127.0.0.1:6060". A non-loopback override (e.g. "0.0.0.0:6060") additionally +// requires debugPprofAllowNonLoopbackEnvVar=1 — see startDebugPprofIfEnabled +// for the rationale. +const debugPprofAddrEnvVar = "ARC_DEBUG_PPROF_ADDR" + +// debugPprofAllowNonLoopbackEnvVar is the explicit second opt-in required +// before pprof binds to a non-loopback interface. Two env vars instead of +// one because "exposing pprof to the network" is a deliberate decision +// that should not happen by typo in the bind-address env var. +const debugPprofAllowNonLoopbackEnvVar = "ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK" + +// debugPprofDefaultAddr is the loopback-only default. Picked over the +// Go-runtime convention `:6060` so a misconfigured firewall cannot make +// the endpoint reachable cross-host. +const debugPprofDefaultAddr = "127.0.0.1:6060" + +// debugPprofReadHeaderTimeout caps how long a client can dribble headers +// in. Slowloris mitigation on the debug surface. +const debugPprofReadHeaderTimeout = 5 * time.Second + +// debugPprofWriteTimeout caps how long the server will spend writing the +// response. pprof captures legitimately run long (`profile?seconds=N`), +// so the bound is intentionally generous — but finite, so a slow reader +// cannot pin a connection forever. +const debugPprofWriteTimeout = 10 * time.Minute + +// debugPprofIdleTimeout closes keep-alive connections that the operator +// leaves open. 60 seconds is short enough to recycle file descriptors +// promptly but long enough that an interactive `curl` workflow doesn't +// fight the listener. +const debugPprofIdleTimeout = 60 * time.Second + +// startDebugPprofIfEnabled starts an opt-in pprof HTTP server on a separate +// listener when ARC_DEBUG_PPROF is truthy. The listener is bound to +// localhost by default and is NEVER attached to the public Fiber app — +// that's the whole point of the gate. Closes audit finding #2 from +// 2026-05-19 (GHSA-j93g-rp6m-j32m). +// +// When the env var is unset or evaluates to false, this is a no-op: pprof +// handlers are not registered on this listener, no socket is opened, no +// goroutine is spawned. An attacker on the data port cannot reach +// `/debug/pprof/*` even in principle. +// +// Note on http.DefaultServeMux pollution: `import "net/http/pprof"` runs +// init() which DOES register handlers on http.DefaultServeMux — there is +// no way to opt out of that side effect. The private *http.ServeMux below +// protects THIS listener from collisions with the default mux; it does +// NOT undo the default-mux registration. Arc safely avoids the side +// effect because the binary contains zero callers of +// http.ListenAndServe(_, nil) or any other path that serves +// http.DefaultServeMux — verified at PR time. A future PR that introduces +// such a caller would inherit pprof handlers on the default mux; track +// that by greppping for `http.DefaultServeMux` and unnamed `http.Server` +// handler fields. +func startDebugPprofIfEnabled(coord *shutdown.Coordinator, logger zerolog.Logger) { + // The caller (cmd/arc/main.go) passes logger.Get("debug-pprof"), which + // already carries the component field. Don't re-attach it; zerolog + // accumulates fields and emitting "component" twice is noise. + if !isTruthy(os.Getenv(debugPprofEnvVar)) { + return + } + + addr := os.Getenv(debugPprofAddrEnvVar) + if addr == "" { + addr = debugPprofDefaultAddr + } + + // Require a second opt-in for non-loopback exposure. "0.0.0.0:6060" is + // a single typo away from the default; making it a deliberate two-step + // matches the fail-closed precedent set by PR #442's sandbox. + if !isLoopbackBindAddr(addr) && !isTruthy(os.Getenv(debugPprofAllowNonLoopbackEnvVar)) { + logger.Error(). + Str("addr", addr). + Msg(debugPprofEnvVar + "=1 with a non-loopback " + debugPprofAddrEnvVar + " requires " + debugPprofAllowNonLoopbackEnvVar + "=1; refusing to start pprof listener") + return + } + + mux := http.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) + + srv := &http.Server{ + // Addr is intentionally not set — srv.Serve(ln) below uses the + // listener's address, and a stale Addr field would mislead anyone + // reading the struct. + Handler: mux, + ReadHeaderTimeout: debugPprofReadHeaderTimeout, + WriteTimeout: debugPprofWriteTimeout, + IdleTimeout: debugPprofIdleTimeout, + } + + // Bind the listener up-front so a port conflict surfaces as a startup + // error rather than a goroutine that silently fails behind a log line. + ln, err := net.Listen("tcp", addr) + if err != nil { + logger.Error().Err(err).Str("addr", addr).Msg(debugPprofEnvVar + "=1 but failed to bind pprof listener; continuing without pprof") + return + } + + logLevel := logger.Warn() + if !isLoopbackBindAddr(addr) { + // Upgrade to Error so default alerting policies notice that pprof + // is exposed cross-host on this node. The operator already opted + // in via ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK so this isn't a + // surprise — just a higher-visibility breadcrumb. + logLevel = logger.Error() + } + logLevel. + Str("addr", addr). + Msg(debugPprofEnvVar + " is set — pprof endpoints are exposed on this address. Restrict access via firewall or unset " + debugPprofEnvVar + " in production.") + + go func() { + // Belt-and-suspenders: close the listener when the goroutine exits. + // srv.Serve(ln) closes ln on return, but if a future refactor swaps + // Serve for something else this defer keeps the fd from leaking. + // Also closes the race window where coord.Shutdown could fire + // between net.Listen returning and Serve being entered — a tiny + // window (microseconds), but `defer ln.Close()` makes it safe. + defer ln.Close() + // Serve returns http.ErrServerClosed on graceful Close/Shutdown. + // Anything else is unexpected and worth logging. + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + logger.Error().Err(err).Msg("pprof listener stopped unexpectedly") + } + }() + + // PriorityHTTPServer (10) — same band as the main HTTP server: stop + // accepting new debug requests early in shutdown. + // + // Use srv.Close() (force-shutdown) instead of srv.Shutdown(ctx) here. + // Shutdown waits for in-flight handlers to drain, and a long-running + // pprof capture (e.g. /debug/pprof/profile?seconds=300) would hold the + // coordinator's shared 30s budget for its full duration. Every + // downstream hook (http-server, compaction, wal, database, storage, + // auth, ...) would then be skipped when the budget exhausted — that's + // a data-loss path on what the operator expected to be a graceful + // exit. Force-close is the right tradeoff for a debug surface: pprof + // captures aren't load-bearing, killing the connection immediately is + // fine. + coord.RegisterHook("debug-pprof", func(_ context.Context) error { + return srv.Close() + }, shutdown.PriorityHTTPServer) +} + +// isLoopbackBindAddr returns true when addr binds only to a loopback +// interface. Used to decide whether ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK is +// required. Accepts every shape `net.Listen("tcp", addr)` accepts: +// +// "127.0.0.1:6060" → loopback +// "localhost:6060" → loopback (resolves to 127.0.0.1 / ::1) +// "[::1]:6060" → loopback +// ":6060" → NOT loopback (binds all interfaces; rejected) +// "0.0.0.0:6060" → NOT loopback (binds all v4 interfaces) +// "192.168.1.5:6060" → NOT loopback +// +// On lookup failure (e.g. unresolvable name), treat as non-loopback so +// the second opt-in is required. Fail closed. +func isLoopbackBindAddr(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + if host == "" { + // ":6060" binds the wildcard address — not loopback. + return false + } + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + // Hostname that isn't "localhost" — could resolve to anything. Don't + // trust the resolver here; require the explicit opt-in. + return false +} + +// isTruthy treats common affirmative env-var values as true. The set is +// deliberately small and case-insensitive on the canonical forms so +// operator config is forgiving without admitting weird shapes like +// "tRuE". +func isTruthy(s string) bool { + switch strings.ToLower(s) { + case "1", "true", "yes", "on": + return true + } + return false +}
cmd/arc/debug_pprof_test.go+204 −0 added@@ -0,0 +1,204 @@ +package main + +import ( + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/basekick-labs/arc/internal/shutdown" + "github.com/rs/zerolog" +) + +// pickFreeLocalPort asks the kernel for an ephemeral port on 127.0.0.1 +// and returns it as a "127.0.0.1:NNNN" string. Two-step ask-then-close +// gives the kernel a few microseconds to reuse the port; we accept the +// tiny race window because the alternative (hard-coding a port) makes +// the test flake on shared CI hosts. +func pickFreeLocalPort(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("kernel could not allocate a free port: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + return addr +} + +// TestStartDebugPprofIfEnabled_NoopWhenDisabled asserts the security +// invariant: when ARC_DEBUG_PPROF is unset (or evaluates to false), the +// helper opens no listener, registers no shutdown hook, and an attacker +// cannot reach pprof in principle. +func TestStartDebugPprofIfEnabled_NoopWhenDisabled(t *testing.T) { + t.Setenv(debugPprofEnvVar, "") + addr := pickFreeLocalPort(t) + t.Setenv(debugPprofAddrEnvVar, addr) + + coord := shutdown.New(5*time.Second, zerolog.Nop()) + startDebugPprofIfEnabled(coord, zerolog.Nop()) + + // If a listener were running, this Dial would succeed. We give it a + // short timeout so the test isn't slowed by the inevitable connection + // refused on a free port. + c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = c.Close() + t.Errorf("listener accepted a connection on %s but ARC_DEBUG_PPROF is unset; pprof must be a true no-op when disabled", addr) + } +} + +// TestStartDebugPprofIfEnabled_BindsAndServes asserts that with the env +// var set, pprof comes up on the configured address, /debug/pprof/ is +// reachable, and a non-pprof path on the same listener 404s (the helper +// uses a private *http.ServeMux, so it must not respond to arbitrary +// URLs). +func TestStartDebugPprofIfEnabled_BindsAndServes(t *testing.T) { + addr := pickFreeLocalPort(t) + t.Setenv(debugPprofEnvVar, "1") + t.Setenv(debugPprofAddrEnvVar, addr) + + coord := shutdown.New(5*time.Second, zerolog.Nop()) + startDebugPprofIfEnabled(coord, zerolog.Nop()) + + // Wait until the listener is actually accepting. The goroutine inside + // startDebugPprofIfEnabled is async; without this poll the test is + // flaky on a busy CI host. + client := &http.Client{Timeout: 2 * time.Second} + url := "http://" + addr + "/debug/pprof/" + deadline := time.Now().Add(2 * time.Second) + var resp *http.Response + var err error + for time.Now().Before(deadline) { + resp, err = client.Get(url) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil { + t.Fatalf("pprof listener never came up at %s: %v", addr, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("/debug/pprof/ on %s: expected 200, got %d", addr, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + // The pprof index page lists the available profiles by name. If we + // got something else, the wrong handler is registered. + if !strings.Contains(string(body), "Profile Descriptions") && !strings.Contains(string(body), "pprof") { + t.Errorf("/debug/pprof/ body does not look like the pprof index page: %q", string(body[:min(200, len(body))])) + } + + // The four non-runtime-profile endpoints (cmdline, profile, symbol, + // trace) MUST be reachable. These do NOT live in the runtime/pprof + // Lookup table, so pprof.Index alone would return 404 "Unknown + // profile" for them — they need explicit HandleFunc registrations + // next to pprof.Index. This sub-test prevents a future "simplification" + // that drops the explicit registrations (e.g. PR #443 review G3 + // suggested it; empirically refuted). + // + // Skip /debug/pprof/profile and /debug/pprof/trace because those + // actively capture data over seconds; the test would either take + // 30+s or have to fight the seconds query param. Cmdline and symbol + // are O(1) and prove the registrations work. + for _, p := range []string{"/debug/pprof/cmdline", "/debug/pprof/symbol"} { + r, err := client.Get("http://" + addr + p) + if err != nil { + t.Errorf("GET %s on pprof listener: %v", p, err) + continue + } + _ = r.Body.Close() + if r.StatusCode == http.StatusNotFound { + t.Errorf("GET %s on pprof listener: 404 — pprof.Index alone does not cover this endpoint; explicit HandleFunc(%q, ...) registration is required", p, p) + } + } + + // Non-pprof path must NOT be served by this listener: we registered a + // fresh *http.ServeMux, not the default mux. A request for + // "/something-else" must 404, proving we did not leak handlers onto + // http.DefaultServeMux. + resp2, err := client.Get("http://" + addr + "/") + if err != nil { + t.Fatalf("GET / on pprof listener: %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusNotFound { + t.Errorf("GET / on pprof listener: expected 404 (fresh ServeMux must not serve arbitrary paths), got %d", resp2.StatusCode) + } + + // Graceful shutdown closes the listener and the goroutine exits. The + // coordinator carries its own timeout (set at New) so Shutdown takes + // no args. + if err := coord.Shutdown(); err != nil { + t.Errorf("pprof listener shutdown returned error: %v", err) + } + + // After shutdown, the same port should no longer accept connections. + c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = c.Close() + t.Errorf("pprof listener at %s still accepts connections after shutdown", addr) + } +} + +// TestIsTruthy pins the env-var truthiness contract. The matcher is +// case-insensitive across the canonical forms (1/true/yes/on); anything +// else — including numeric-other-than-1 — is false. +func TestIsTruthy(t *testing.T) { + t.Parallel() + truthy := []string{ + "1", "true", "TRUE", "True", "tRuE", + "yes", "YES", "Yes", + "on", "ON", "On", + } + falsy := []string{ + "", "0", "false", "no", "off", + "FALSE", "No", "Off", + "anything-else", "2", "enabled", "y", "t", + } + for _, s := range truthy { + if !isTruthy(s) { + t.Errorf("isTruthy(%q) = false, want true", s) + } + } + for _, s := range falsy { + if isTruthy(s) { + t.Errorf("isTruthy(%q) = true, want false", s) + } + } +} + +// TestIsLoopbackBindAddr pins the loopback-detection contract. Drives the +// debugPprofAllowNonLoopbackEnvVar fail-closed branch in +// startDebugPprofIfEnabled. +func TestIsLoopbackBindAddr(t *testing.T) { + t.Parallel() + loopback := []string{ + "127.0.0.1:6060", + "localhost:6060", + "[::1]:6060", + "127.0.0.5:6060", + } + nonLoopback := []string{ + ":6060", // wildcard + "0.0.0.0:6060", // v4 wildcard + "[::]:6060", // v6 wildcard + "192.168.1.5:6060", // LAN + "10.0.0.1:6060", // RFC 1918 + "example.com:6060", // unresolved hostname — fail-closed + "not-a-valid-addr", // SplitHostPort fails — fail-closed + } + for _, s := range loopback { + if !isLoopbackBindAddr(s) { + t.Errorf("isLoopbackBindAddr(%q) = false, want true", s) + } + } + for _, s := range nonLoopback { + if isLoopbackBindAddr(s) { + t.Errorf("isLoopbackBindAddr(%q) = true, want false", s) + } + } +}
cmd/arc/main.go+23 −2 modified@@ -177,6 +177,14 @@ func main() { // Initialize shutdown coordinator shutdownCoordinator := shutdown.New(30*time.Second, logger.Get("shutdown")) + // Opt-in pprof on a localhost listener (no-op unless ARC_DEBUG_PPROF=1). + // Replaces the previous behaviour where pprof was unconditionally + // mounted on the public Fiber app and any network-reachable caller + // could fetch heap dumps or pin a CPU core via /debug/pprof/profile. + // See cmd/arc/debug_pprof.go for the rationale. Closes audit #2 + // (GHSA-j93g-rp6m-j32m) from 2026-05-19. + startDebugPprofIfEnabled(shutdownCoordinator, logger.Get("debug-pprof")) + // arcx (Arc Enterprise DuckDB extension): gate via license before // passing the path down to the DB layer. The extension binary is the // licensing perimeter, but having Arc refuse to LOAD without a valid @@ -1400,7 +1408,12 @@ func main() { // without auth tokens after compaction. Access is gated by X-Arc-Internal header // validation in the handler. Cluster nodes should be on a private network. middlewareConfig.PublicRoutes = append(middlewareConfig.PublicRoutes, "/health", "/ready", "/api/v1/auth/verify", "/api/v1/internal/cache/invalidate") - middlewareConfig.PublicPrefixes = append(middlewareConfig.PublicPrefixes, "/metrics", "/debug/pprof") + // /metrics stays public — Prometheus scrapers expect it. It is + // already in auth.DefaultMiddlewareConfig().PublicPrefixes, so no + // further append is needed here. /debug/pprof is intentionally NOT + // here: pprof is no longer mounted on the public Fiber app + // (internal/api/server.go). The opt-in localhost pprof listener + // runs on a separate port; see startDebugPprofIfEnabled. server.GetApp().Use(auth.NewMiddleware(middlewareConfig)) // Initialize RBAC Manager (Enterprise feature) @@ -2062,7 +2075,15 @@ func main() { auditHandler.RegisterRoutes(server.GetApp()) } - // Register HTTP server shutdown hook (first to stop accepting new requests) + // Register HTTP server shutdown hook (first to stop accepting new + // requests). The 30-second arg is the HTTP server's INTERNAL drain + // timeout — independent of shutdownCoordinator's own 30-second + // budget. If the coordinator ctx expires before this hook completes, + // the coordinator skips remaining hooks; the internal timeout is a + // best-effort upper bound on this hook's slice of that budget. The + // debug-pprof hook (registered earlier in main, same priority) uses + // srv.Close() instead of Shutdown(ctx) to avoid letting a long + // /debug/pprof/profile?seconds=N capture starve this hook. shutdownCoordinator.RegisterHook("http-server", func(ctx context.Context) error { return server.Shutdown(30 * time.Second) }, shutdown.PriorityHTTPServer)
internal/api/server.go+6 −3 modified@@ -15,7 +15,6 @@ import ( "github.com/basekick-labs/arc/internal/metrics" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/gofiber/fiber/v2/middleware/pprof" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/rs/zerolog" ) @@ -127,8 +126,12 @@ func NewServer(config *ServerConfig, logger zerolog.Logger) *Server { // This prevents double-decompression issues with gzip-compressed MessagePack payloads // Response compression is still available via Accept-Encoding handling - // pprof profiling endpoints - app.Use(pprof.New()) + // pprof is NOT registered on the public Fiber app. It used to be — + // `app.Use(pprof.New())` — but that exposed `/debug/pprof/*` to any + // network-reachable caller (auth middleware short-circuited it via + // PublicPrefixes). See cmd/arc/main.go for the opt-in localhost-bound + // pprof listener started only when ARC_DEBUG_PPROF=1. + // Closes audit finding #2 from 2026-05-19 (GHSA-j93g-rp6m-j32m). // Request logging middleware app.Use(requestLogger(logger))
internal/api/server_pprof_test.go+63 −0 added@@ -0,0 +1,63 @@ +package api + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog" +) + +// TestServer_PprofNotRegisteredOnPublicApp is the regression test for audit +// finding #2 (GHSA-j93g-rp6m-j32m): the previous default mounted +// `/debug/pprof/*` on the public Fiber app and added `/debug/pprof` to +// PublicPrefixes, so any network-reachable caller could fetch heap dumps +// or pin a CPU core via `/debug/pprof/profile?seconds=30`. +// +// After the fix, the public Fiber app must NEVER serve `/debug/pprof/*`. +// pprof is opt-in via ARC_DEBUG_PPROF=1 and binds to a separate +// localhost-only listener — that's tested in cmd/arc/debug_pprof_test.go +// (the binary's integration surface). This test pins the library +// invariant: NewServer's app does not mount pprof handlers. +// +// Probed paths cover the surface gofiber/fiber/v2/middleware/pprof used +// to register: index, heap, profile, goroutine, allocs, mutex, block, +// trace, cmdline, symbol. Each must 404 — Fiber returns 404 for any +// route the app doesn't know about, including before the auth +// middleware fires, which is the security guarantee we want. +func TestServer_PprofNotRegisteredOnPublicApp(t *testing.T) { + t.Parallel() + srv := NewServer(DefaultServerConfig(), zerolog.Nop()) + app := srv.GetApp() + + pprofPaths := []string{ + "/debug/pprof/", + "/debug/pprof/heap", + "/debug/pprof/profile", + "/debug/pprof/profile?seconds=1", + "/debug/pprof/goroutine", + "/debug/pprof/allocs", + "/debug/pprof/mutex", + "/debug/pprof/block", + "/debug/pprof/threadcreate", + "/debug/pprof/trace", + "/debug/pprof/cmdline", + "/debug/pprof/symbol", + } + for _, p := range pprofPaths { + t.Run(p, func(t *testing.T) { + req := httptest.NewRequest("GET", p, nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + // Anything other than 404 means pprof is reachable on the + // public app — that's the bug. We accept 404; we reject 200, + // 500, 302, etc. (Fiber's default not-found returns 404.) + if resp.StatusCode != fiber.StatusNotFound { + t.Errorf("path %q: expected 404 (pprof must not be mounted), got %d", + p, resp.StatusCode) + } + }) + } +}
internal/auth/middleware.go+51 −4 modified@@ -1,6 +1,7 @@ package auth import ( + "path" "strings" "sync" @@ -51,15 +52,61 @@ func NewMiddleware(config MiddlewareConfig) fiber.Handler { return c.Next() } - // Check if route is public - path := c.Path() + // Normalize the request path before public-route / prefix matching. + // c.Path() is the raw fasthttp PathOriginal — Fiber does NOT + // resolve `..`, `.`, or `//` before handing it to middleware. Two + // concrete failure modes the un-normalized form admits: + // + // - `/metrics//foo` lexically starts with `/metrics/`, so the + // anchored HasPrefix below admits it; today it falls through + // to a Fiber 404 because the router also doesn't normalize, + // but the day someone wires up a wildcard route this becomes + // a real bypass. + // - `/metrics/../api/v1/query` lexically starts with `/metrics/` + // and the bypass branch fires; same dead-end on routing today, + // same future hazard. + // + // path.Clean collapses `//` into `/`, removes `/.` segments, and + // resolves `/..` against the preceding segment, so the matching + // below operates on the canonical form a downstream router would + // see. This is also belt-and-suspenders against the audit-log + // pollution surface — the audit middleware logs c.Path() verbatim, + // so an attacker who can pollute lookup with weird paths can also + // pollute the audit trail. + reqPath := path.Clean(c.Path()) for _, route := range config.PublicRoutes { - if path == route { + if reqPath == route { return c.Next() } } for _, prefix := range config.PublicPrefixes { - if strings.HasPrefix(path, prefix) { + // Normalise prefix: strip exactly one trailing slash. An + // operator who reads "this is a directory prefix" and writes + // `/metrics/` in their config would otherwise silently break + // the anchored match: prefix+"/" becomes "/metrics//" which + // matches no real path. Symmetric with the request-path + // normalisation above; both sides should be in canonical form + // before the equality and HasPrefix checks. We deliberately + // don't `path.Clean` the prefix because (a) it's + // operator-supplied static config, not attacker-controlled, + // and (b) Clean would also collapse a configured `//foo` to + // `/foo`, hiding the misconfiguration instead of treating it + // as the same prefix the operator wrote. + prefix = strings.TrimSuffix(prefix, "/") + // Guard against empty-prefix entries (including a configured + // `/` that the TrimSuffix above just emptied). `HasPrefix(p, "/")` + // is true for every real HTTP path, so an empty prefix would + // disable auth wholesale. + if prefix == "" { + continue + } + // Anchor the match: require exact path or true subdirectory + // (`prefix + "/"`). Without this, a configured prefix `/metrics` + // also matches `/metricsX`, `/metrics-secret`, etc. — any route + // happening to share the same byte prefix would silently bypass + // auth. Matches the same shape gemini-code-assist flagged on + // PR #442's deniedRoots check. + if reqPath == prefix || strings.HasPrefix(reqPath, prefix+"/") { return c.Next() } }
internal/auth/middleware_test.go+137 −0 modified@@ -226,6 +226,143 @@ func TestMiddleware_PublicPrefixes(t *testing.T) { } } +// TestMiddleware_PublicPrefixes_AnchoredMatch is the regression test for the +// gemini-flagged anchoring gap. Before the fix, strings.HasPrefix(path, "/metrics") +// would silently let /metricsX, /metrics-secret, /metricsadmin bypass auth — +// any route happening to share the byte prefix slipped through. The fix +// requires exact-equal or true-subdirectory (`prefix + "/"`) matching after +// path.Clean normalisation, so non-canonical shapes like `/metrics//foo`, +// `/metrics/./x`, and `/metrics/../X` cannot pollute the bypass branch +// either. +// +// We assert against status, not against registered handlers. Bypass paths +// (200 == handler ran) get a real handler; non-bypass paths (401 == auth +// returned without c.Next) do not — Fiber's middleware short-circuits +// before route lookup. +func TestMiddleware_PublicPrefixes_AnchoredMatch(t *testing.T) { + config := DefaultMiddlewareConfig() + // Inject an empty string and a non-empty prefix together. The empty + // entry tests the empty-prefix guard (must NOT make every request a + // bypass); the non-empty entry tests the anchored-match contract. + config.PublicPrefixes = []string{"", "/metrics"} + + _, app, cleanup := setupMiddlewareTest(t, config) + defer cleanup() + + // Only register the bypass paths — 401 paths short-circuit before + // route dispatch, so handlers aren't required. + for _, p := range []string{ + "/metrics", "/metrics/", "/metrics/prometheus", "/metrics/sub/leaf", + } { + app.Get(p, func(c *fiber.Ctx) error { return c.SendString("ok") }) + } + + tests := []struct { + name string + path string + expectedStatus int + why string + }{ + // Positive: must bypass. + {"exact match", "/metrics", fiber.StatusOK, "exact-equal must bypass"}, + {"trailing-slash match", "/metrics/", fiber.StatusOK, "prefix + '/' must bypass"}, + {"true subdirectory", "/metrics/prometheus", fiber.StatusOK, "true subdir must bypass"}, + {"deep subdirectory", "/metrics/sub/leaf", fiber.StatusOK, "deep subdir must bypass"}, + + // Negative: sibling shapes that share the prefix bytes but aren't subdirectories. + {"sibling path with same prefix bytes", "/metricsX", fiber.StatusUnauthorized, "/metricsX is NOT a subdir of /metrics"}, + {"sibling with separator-shaped suffix", "/metrics-secret", fiber.StatusUnauthorized, "/metrics-secret is NOT a subdir of /metrics"}, + {"sibling alphanum suffix", "/metricsadmin", fiber.StatusUnauthorized, "/metricsadmin is NOT a subdir of /metrics"}, + + // Negative: parent-traversal shapes that path.Clean must normalise + // AWAY from the /metrics tree before the bypass branch checks + // them. The middleware sees the normalised form and correctly + // rejects the request (401). We only test escape-the-prefix + // shapes here because non-escaping shapes like `/metrics//foo` and + // `/metrics/./foo` — while correctly bypassed by the middleware — + // hit a Fiber router that DOES NOT normalise, so they 404 instead + // of reaching the registered handler. Asserting 404 would couple + // the test to Fiber's routing behaviour; asserting NOT-401 (i.e. + // auth bypass happened) is what we actually care about, and the + // security-relevant assertion is that escape shapes return 401. + {"parent-traversal escapes the prefix", "/metrics/../sensitive", fiber.StatusUnauthorized, "after path.Clean → /sensitive, NOT under /metrics"}, + {"parent-traversal escapes via leading dot-dot", "/metrics/sub/../../etc", fiber.StatusUnauthorized, "after path.Clean → /etc, NOT under /metrics"}, + + // Empty-prefix guard: an empty entry in PublicPrefixes must NOT + // cause `/any-path` to be treated as bypass. If the guard breaks, + // /any-other-path would return 200 instead of 401. + {"empty prefix entry does not match unrelated paths", "/some/random/api", fiber.StatusUnauthorized, "empty-string prefix must be skipped, NOT match every path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + if resp.StatusCode != tt.expectedStatus { + t.Errorf("path %q: expected status %d, got %d (%s)", + tt.path, tt.expectedStatus, resp.StatusCode, tt.why) + } + }) + } +} + +// TestMiddleware_PublicPrefixes_TrailingSlashNormalisation is the +// regression test for the gemini-r2 finding: a configured prefix with a +// trailing slash (e.g. `/metrics/`) used to break the anchored match +// because `prefix + "/"` produced `/metrics//`, which lexically matches +// no real request path. The fix strips exactly one trailing slash from +// the prefix before the anchored check, so configured `/metrics/` and +// configured `/metrics` are byte-identical at match time. Test both +// shapes against the same set of request paths to pin that equivalence. +func TestMiddleware_PublicPrefixes_TrailingSlashNormalisation(t *testing.T) { + configs := []struct { + name string + prefix string + }{ + {"prefix without trailing slash", "/metrics"}, + {"prefix WITH trailing slash", "/metrics/"}, + } + for _, cfg := range configs { + cfg := cfg // capture for parallel subtests + t.Run(cfg.name, func(t *testing.T) { + mw := DefaultMiddlewareConfig() + mw.PublicPrefixes = []string{cfg.prefix} + + _, app, cleanup := setupMiddlewareTest(t, mw) + defer cleanup() + app.Get("/metrics", func(c *fiber.Ctx) error { return c.SendString("ok") }) + app.Get("/metrics/prometheus", func(c *fiber.Ctx) error { return c.SendString("ok") }) + + tests := []struct { + name string + path string + expectedStatus int + }{ + {"exact match", "/metrics", fiber.StatusOK}, + {"true subdirectory", "/metrics/prometheus", fiber.StatusOK}, + {"sibling byte-prefix", "/metricsX", fiber.StatusUnauthorized}, + {"parent traversal escape", "/metrics/../sensitive", fiber.StatusUnauthorized}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + if resp.StatusCode != tt.expectedStatus { + t.Errorf("prefix=%q path=%q: expected status %d, got %d", + cfg.prefix, tt.path, tt.expectedStatus, resp.StatusCode) + } + }) + } + }) + } +} + // TestMiddleware_NoToken tests request without any token func TestMiddleware_NoToken(t *testing.T) { config := DefaultMiddlewareConfig()
RELEASE_NOTES_2026.06.1.md+29 −0 modified@@ -35,6 +35,35 @@ The arcx loader was simplified: the previous per-connection `connInitFn` (which Tests added: `TestSandbox` (CVE reproduction + full I/O family + SSRF + `COPY TO` local + `COPY TO 's3://...'` + `EXPORT DATABASE` outside allowlist + `INSTALL` after lockdown + cross-connection enforcement + `range()` remains callable + lockdown is one-way), `TestBuildAllowedDirectories` (12 table cases covering hot/cold S3 dedup, leading/interior-slash collapse, trailing-slash idempotence, empty-config behavior), `TestSandboxEmptyAllowlistLogsButDoesNotPanic`. The existing arcx tests confirm `arcx_version()` and `SET GLOBAL arcx.storage_root` propagate across 3–4 concurrent pool connections. +### Go pprof endpoints no longer reachable from the public API port + +Reported by [Alex Manson](https://neurowinter.com/) ([@NeuroWinter](https://github.com/NeuroWinter)) — thank you for the detailed report. + +Pre-26.06.1, `internal/api/server.go` called `app.Use(pprof.New())` unconditionally on the public Fiber app, and `cmd/arc/main.go` added `/debug/pprof` to the auth middleware's `PublicPrefixes` list. The combined effect: any network-reachable caller — no token, no auth header, no anything — could fetch: + +- `GET /debug/pprof/heap` — leaks in-memory state (live SQL strings, decoded msgpack records, decompressed request bodies, cached `*TokenInfo` derived from plaintext-token hashes in the auth cache). +- `GET /debug/pprof/goroutine?debug=2` — leaks every goroutine's call stack, identifying internal code paths and surfaces. +- `GET /debug/pprof/profile?seconds=N` — pins a CPU core for arbitrary duration. One request = minutes of server CPU. Trivial DoS amplification. +- `GET /debug/pprof/trace?seconds=N` — same CPU-burn profile via a different handler. + +26.06.1 removes pprof from the public Fiber app entirely. The endpoints are now opt-in via `ARC_DEBUG_PPROF=1`, and when enabled they bind to a separate `127.0.0.1:6060` listener (override via `ARC_DEBUG_PPROF_ADDR`; a non-loopback bind additionally requires `ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK=1` so a single typo in the address knob cannot expose pprof to the network). The localhost listener registers its handlers on a private `*http.ServeMux`, NOT on `http.DefaultServeMux` — so even if a future PR adds an `http.Server` somewhere with `Handler: nil`, that server will not unintentionally serve pprof (verified at merge time that no such caller exists in the binary). The listener is wired into the existing shutdown coordinator at `PriorityHTTPServer` and shuts down via `srv.Close()` (force-close, not `srv.Shutdown`) so an in-flight long-running `/debug/pprof/profile?seconds=N` capture cannot pin the coordinator's shared 30-second shutdown budget and starve downstream shutdown hooks. Server-side timeouts (`ReadHeaderTimeout=5s`, `WriteTimeout=10m`, `IdleTimeout=60s`) bound slow-client attacks on the debug surface. + +Operator-facing changes: + +- **Default behavior changed**: pprof is no longer reachable on Arc's API port (`:8000` by default). Existing deployments that relied on `curl http://arc:8000/debug/pprof/heap` will start getting `404 Not Found`. Set `ARC_DEBUG_PPROF=1` and reach pprof on `127.0.0.1:6060` instead. +- **`/metrics` is unchanged**: Prometheus scrapers continue to work as before. Only `/debug/pprof/*` moved. +- **Non-loopback bind requires a second opt-in**: `ARC_DEBUG_PPROF_ADDR` accepts any bind string Go's `net.Listen` understands, but a non-loopback override (e.g. `0.0.0.0:6060`) additionally requires `ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK=1`. Without that second env var Arc logs an `Error` and refuses to start the pprof listener; with it, Arc logs an `Error` (not Warn) at startup naming the bound address so cross-host exposure shows up in default alerting policies. + +A **defense-in-depth fix to the auth middleware's `PublicPrefixes` matcher** is also included. The previous `strings.HasPrefix(path, prefix)` would match `/metrics` against `/metrics`, `/metrics/prometheus`, AND `/metricsX`, `/metrics-secret`, etc. — any byte-prefix match silently bypassed auth. Three changes: + +1. **Anchored prefix match**: the matcher now requires exact-equal or true-subdirectory (`prefix + "/"`); a sibling path with the same prefix bytes no longer slips through. Same shape as the prefix-match gap gemini-code-assist flagged on PR #442's `deniedRoots` check. +2. **Path normalisation before match**: the matcher runs `path.Clean(c.Path())` first, so non-canonical request shapes like `/metrics//foo`, `/metrics/./x`, `/metrics/../sensitive` are normalised to their canonical form before the bypass branch checks them. Without normalisation, an attacker-controlled `/metrics/../api/v1/query` lexically starts with `/metrics/` and would slip past the anchored check; after normalisation it becomes `/api/v1/query` and correctly requires auth. +3. **Empty-prefix guard**: an empty string in `PublicPrefixes` (no legitimate config has one, but a future bug — e.g. an env-var split producing an empty slice entry — could) would otherwise short-circuit every request because `HasPrefix(anyPath, "")` is true. The matcher now skips empty entries. + +With `/debug/pprof` removed from the public prefix list, items 1 and 2 are not currently reachable for any production route — the fixes are guards for any prefix added in the future. + +Tests added: `TestServer_PprofNotRegisteredOnPublicApp` (12 pprof paths against the public Fiber app, all must 404), `TestMiddleware_PublicPrefixes_AnchoredMatch` (10 subtests: exact match + trailing-slash match + true subdir + deep subdir bypass + 3 sibling-byte-prefix shapes that must require auth + 2 parent-traversal escape shapes that must require auth + empty-prefix guard), plus the new `cmd/arc/debug_pprof_test.go` (no-op when disabled, binds-and-serves when enabled, `isTruthy` env-var contract, `isLoopbackBindAddr` detection incl. fail-closed on unresolvable hosts). + ## Bug fixes ### Parser no longer mis-resolves bare `time` column inside `EXTRACT(YEAR FROM time)`
32a4091fb949fix(security): gate pprof behind ARC_DEBUG_PPROF on a localhost listener + anchor + normalise PublicPrefixes match (#443)
8 files changed · +725 −9
cmd/arc/debug_pprof.go+212 −0 added@@ -0,0 +1,212 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/http/pprof" + "os" + "strings" + "time" + + "github.com/basekick-labs/arc/internal/shutdown" + "github.com/rs/zerolog" +) + +// debugPprofEnvVar is the env-var operators set to opt into the localhost +// pprof listener. Any value matching the isTruthy contract turns it on; +// everything else (including unset) leaves it off. +const debugPprofEnvVar = "ARC_DEBUG_PPROF" + +// debugPprofAddrEnvVar lets operators override the bind address. Default is +// "127.0.0.1:6060". A non-loopback override (e.g. "0.0.0.0:6060") additionally +// requires debugPprofAllowNonLoopbackEnvVar=1 — see startDebugPprofIfEnabled +// for the rationale. +const debugPprofAddrEnvVar = "ARC_DEBUG_PPROF_ADDR" + +// debugPprofAllowNonLoopbackEnvVar is the explicit second opt-in required +// before pprof binds to a non-loopback interface. Two env vars instead of +// one because "exposing pprof to the network" is a deliberate decision +// that should not happen by typo in the bind-address env var. +const debugPprofAllowNonLoopbackEnvVar = "ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK" + +// debugPprofDefaultAddr is the loopback-only default. Picked over the +// Go-runtime convention `:6060` so a misconfigured firewall cannot make +// the endpoint reachable cross-host. +const debugPprofDefaultAddr = "127.0.0.1:6060" + +// debugPprofReadHeaderTimeout caps how long a client can dribble headers +// in. Slowloris mitigation on the debug surface. +const debugPprofReadHeaderTimeout = 5 * time.Second + +// debugPprofWriteTimeout caps how long the server will spend writing the +// response. pprof captures legitimately run long (`profile?seconds=N`), +// so the bound is intentionally generous — but finite, so a slow reader +// cannot pin a connection forever. +const debugPprofWriteTimeout = 10 * time.Minute + +// debugPprofIdleTimeout closes keep-alive connections that the operator +// leaves open. 60 seconds is short enough to recycle file descriptors +// promptly but long enough that an interactive `curl` workflow doesn't +// fight the listener. +const debugPprofIdleTimeout = 60 * time.Second + +// startDebugPprofIfEnabled starts an opt-in pprof HTTP server on a separate +// listener when ARC_DEBUG_PPROF is truthy. The listener is bound to +// localhost by default and is NEVER attached to the public Fiber app — +// that's the whole point of the gate. Closes audit finding #2 from +// 2026-05-19 (GHSA-j93g-rp6m-j32m). +// +// When the env var is unset or evaluates to false, this is a no-op: pprof +// handlers are not registered on this listener, no socket is opened, no +// goroutine is spawned. An attacker on the data port cannot reach +// `/debug/pprof/*` even in principle. +// +// Note on http.DefaultServeMux pollution: `import "net/http/pprof"` runs +// init() which DOES register handlers on http.DefaultServeMux — there is +// no way to opt out of that side effect. The private *http.ServeMux below +// protects THIS listener from collisions with the default mux; it does +// NOT undo the default-mux registration. Arc safely avoids the side +// effect because the binary contains zero callers of +// http.ListenAndServe(_, nil) or any other path that serves +// http.DefaultServeMux — verified at PR time. A future PR that introduces +// such a caller would inherit pprof handlers on the default mux; track +// that by greppping for `http.DefaultServeMux` and unnamed `http.Server` +// handler fields. +func startDebugPprofIfEnabled(coord *shutdown.Coordinator, logger zerolog.Logger) { + // The caller (cmd/arc/main.go) passes logger.Get("debug-pprof"), which + // already carries the component field. Don't re-attach it; zerolog + // accumulates fields and emitting "component" twice is noise. + if !isTruthy(os.Getenv(debugPprofEnvVar)) { + return + } + + addr := os.Getenv(debugPprofAddrEnvVar) + if addr == "" { + addr = debugPprofDefaultAddr + } + + // Require a second opt-in for non-loopback exposure. "0.0.0.0:6060" is + // a single typo away from the default; making it a deliberate two-step + // matches the fail-closed precedent set by PR #442's sandbox. + if !isLoopbackBindAddr(addr) && !isTruthy(os.Getenv(debugPprofAllowNonLoopbackEnvVar)) { + logger.Error(). + Str("addr", addr). + Msg(debugPprofEnvVar + "=1 with a non-loopback " + debugPprofAddrEnvVar + " requires " + debugPprofAllowNonLoopbackEnvVar + "=1; refusing to start pprof listener") + return + } + + mux := http.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) + + srv := &http.Server{ + // Addr is intentionally not set — srv.Serve(ln) below uses the + // listener's address, and a stale Addr field would mislead anyone + // reading the struct. + Handler: mux, + ReadHeaderTimeout: debugPprofReadHeaderTimeout, + WriteTimeout: debugPprofWriteTimeout, + IdleTimeout: debugPprofIdleTimeout, + } + + // Bind the listener up-front so a port conflict surfaces as a startup + // error rather than a goroutine that silently fails behind a log line. + ln, err := net.Listen("tcp", addr) + if err != nil { + logger.Error().Err(err).Str("addr", addr).Msg(debugPprofEnvVar + "=1 but failed to bind pprof listener; continuing without pprof") + return + } + + logLevel := logger.Warn() + if !isLoopbackBindAddr(addr) { + // Upgrade to Error so default alerting policies notice that pprof + // is exposed cross-host on this node. The operator already opted + // in via ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK so this isn't a + // surprise — just a higher-visibility breadcrumb. + logLevel = logger.Error() + } + logLevel. + Str("addr", addr). + Msg(debugPprofEnvVar + " is set — pprof endpoints are exposed on this address. Restrict access via firewall or unset " + debugPprofEnvVar + " in production.") + + go func() { + // Belt-and-suspenders: close the listener when the goroutine exits. + // srv.Serve(ln) closes ln on return, but if a future refactor swaps + // Serve for something else this defer keeps the fd from leaking. + // Also closes the race window where coord.Shutdown could fire + // between net.Listen returning and Serve being entered — a tiny + // window (microseconds), but `defer ln.Close()` makes it safe. + defer ln.Close() + // Serve returns http.ErrServerClosed on graceful Close/Shutdown. + // Anything else is unexpected and worth logging. + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + logger.Error().Err(err).Msg("pprof listener stopped unexpectedly") + } + }() + + // PriorityHTTPServer (10) — same band as the main HTTP server: stop + // accepting new debug requests early in shutdown. + // + // Use srv.Close() (force-shutdown) instead of srv.Shutdown(ctx) here. + // Shutdown waits for in-flight handlers to drain, and a long-running + // pprof capture (e.g. /debug/pprof/profile?seconds=300) would hold the + // coordinator's shared 30s budget for its full duration. Every + // downstream hook (http-server, compaction, wal, database, storage, + // auth, ...) would then be skipped when the budget exhausted — that's + // a data-loss path on what the operator expected to be a graceful + // exit. Force-close is the right tradeoff for a debug surface: pprof + // captures aren't load-bearing, killing the connection immediately is + // fine. + coord.RegisterHook("debug-pprof", func(_ context.Context) error { + return srv.Close() + }, shutdown.PriorityHTTPServer) +} + +// isLoopbackBindAddr returns true when addr binds only to a loopback +// interface. Used to decide whether ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK is +// required. Accepts every shape `net.Listen("tcp", addr)` accepts: +// +// "127.0.0.1:6060" → loopback +// "localhost:6060" → loopback (resolves to 127.0.0.1 / ::1) +// "[::1]:6060" → loopback +// ":6060" → NOT loopback (binds all interfaces; rejected) +// "0.0.0.0:6060" → NOT loopback (binds all v4 interfaces) +// "192.168.1.5:6060" → NOT loopback +// +// On lookup failure (e.g. unresolvable name), treat as non-loopback so +// the second opt-in is required. Fail closed. +func isLoopbackBindAddr(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + if host == "" { + // ":6060" binds the wildcard address — not loopback. + return false + } + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + // Hostname that isn't "localhost" — could resolve to anything. Don't + // trust the resolver here; require the explicit opt-in. + return false +} + +// isTruthy treats common affirmative env-var values as true. The set is +// deliberately small and case-insensitive on the canonical forms so +// operator config is forgiving without admitting weird shapes like +// "tRuE". +func isTruthy(s string) bool { + switch strings.ToLower(s) { + case "1", "true", "yes", "on": + return true + } + return false +}
cmd/arc/debug_pprof_test.go+204 −0 added@@ -0,0 +1,204 @@ +package main + +import ( + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/basekick-labs/arc/internal/shutdown" + "github.com/rs/zerolog" +) + +// pickFreeLocalPort asks the kernel for an ephemeral port on 127.0.0.1 +// and returns it as a "127.0.0.1:NNNN" string. Two-step ask-then-close +// gives the kernel a few microseconds to reuse the port; we accept the +// tiny race window because the alternative (hard-coding a port) makes +// the test flake on shared CI hosts. +func pickFreeLocalPort(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("kernel could not allocate a free port: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + return addr +} + +// TestStartDebugPprofIfEnabled_NoopWhenDisabled asserts the security +// invariant: when ARC_DEBUG_PPROF is unset (or evaluates to false), the +// helper opens no listener, registers no shutdown hook, and an attacker +// cannot reach pprof in principle. +func TestStartDebugPprofIfEnabled_NoopWhenDisabled(t *testing.T) { + t.Setenv(debugPprofEnvVar, "") + addr := pickFreeLocalPort(t) + t.Setenv(debugPprofAddrEnvVar, addr) + + coord := shutdown.New(5*time.Second, zerolog.Nop()) + startDebugPprofIfEnabled(coord, zerolog.Nop()) + + // If a listener were running, this Dial would succeed. We give it a + // short timeout so the test isn't slowed by the inevitable connection + // refused on a free port. + c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = c.Close() + t.Errorf("listener accepted a connection on %s but ARC_DEBUG_PPROF is unset; pprof must be a true no-op when disabled", addr) + } +} + +// TestStartDebugPprofIfEnabled_BindsAndServes asserts that with the env +// var set, pprof comes up on the configured address, /debug/pprof/ is +// reachable, and a non-pprof path on the same listener 404s (the helper +// uses a private *http.ServeMux, so it must not respond to arbitrary +// URLs). +func TestStartDebugPprofIfEnabled_BindsAndServes(t *testing.T) { + addr := pickFreeLocalPort(t) + t.Setenv(debugPprofEnvVar, "1") + t.Setenv(debugPprofAddrEnvVar, addr) + + coord := shutdown.New(5*time.Second, zerolog.Nop()) + startDebugPprofIfEnabled(coord, zerolog.Nop()) + + // Wait until the listener is actually accepting. The goroutine inside + // startDebugPprofIfEnabled is async; without this poll the test is + // flaky on a busy CI host. + client := &http.Client{Timeout: 2 * time.Second} + url := "http://" + addr + "/debug/pprof/" + deadline := time.Now().Add(2 * time.Second) + var resp *http.Response + var err error + for time.Now().Before(deadline) { + resp, err = client.Get(url) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil { + t.Fatalf("pprof listener never came up at %s: %v", addr, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("/debug/pprof/ on %s: expected 200, got %d", addr, resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + // The pprof index page lists the available profiles by name. If we + // got something else, the wrong handler is registered. + if !strings.Contains(string(body), "Profile Descriptions") && !strings.Contains(string(body), "pprof") { + t.Errorf("/debug/pprof/ body does not look like the pprof index page: %q", string(body[:min(200, len(body))])) + } + + // The four non-runtime-profile endpoints (cmdline, profile, symbol, + // trace) MUST be reachable. These do NOT live in the runtime/pprof + // Lookup table, so pprof.Index alone would return 404 "Unknown + // profile" for them — they need explicit HandleFunc registrations + // next to pprof.Index. This sub-test prevents a future "simplification" + // that drops the explicit registrations (e.g. PR #443 review G3 + // suggested it; empirically refuted). + // + // Skip /debug/pprof/profile and /debug/pprof/trace because those + // actively capture data over seconds; the test would either take + // 30+s or have to fight the seconds query param. Cmdline and symbol + // are O(1) and prove the registrations work. + for _, p := range []string{"/debug/pprof/cmdline", "/debug/pprof/symbol"} { + r, err := client.Get("http://" + addr + p) + if err != nil { + t.Errorf("GET %s on pprof listener: %v", p, err) + continue + } + _ = r.Body.Close() + if r.StatusCode == http.StatusNotFound { + t.Errorf("GET %s on pprof listener: 404 — pprof.Index alone does not cover this endpoint; explicit HandleFunc(%q, ...) registration is required", p, p) + } + } + + // Non-pprof path must NOT be served by this listener: we registered a + // fresh *http.ServeMux, not the default mux. A request for + // "/something-else" must 404, proving we did not leak handlers onto + // http.DefaultServeMux. + resp2, err := client.Get("http://" + addr + "/") + if err != nil { + t.Fatalf("GET / on pprof listener: %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusNotFound { + t.Errorf("GET / on pprof listener: expected 404 (fresh ServeMux must not serve arbitrary paths), got %d", resp2.StatusCode) + } + + // Graceful shutdown closes the listener and the goroutine exits. The + // coordinator carries its own timeout (set at New) so Shutdown takes + // no args. + if err := coord.Shutdown(); err != nil { + t.Errorf("pprof listener shutdown returned error: %v", err) + } + + // After shutdown, the same port should no longer accept connections. + c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = c.Close() + t.Errorf("pprof listener at %s still accepts connections after shutdown", addr) + } +} + +// TestIsTruthy pins the env-var truthiness contract. The matcher is +// case-insensitive across the canonical forms (1/true/yes/on); anything +// else — including numeric-other-than-1 — is false. +func TestIsTruthy(t *testing.T) { + t.Parallel() + truthy := []string{ + "1", "true", "TRUE", "True", "tRuE", + "yes", "YES", "Yes", + "on", "ON", "On", + } + falsy := []string{ + "", "0", "false", "no", "off", + "FALSE", "No", "Off", + "anything-else", "2", "enabled", "y", "t", + } + for _, s := range truthy { + if !isTruthy(s) { + t.Errorf("isTruthy(%q) = false, want true", s) + } + } + for _, s := range falsy { + if isTruthy(s) { + t.Errorf("isTruthy(%q) = true, want false", s) + } + } +} + +// TestIsLoopbackBindAddr pins the loopback-detection contract. Drives the +// debugPprofAllowNonLoopbackEnvVar fail-closed branch in +// startDebugPprofIfEnabled. +func TestIsLoopbackBindAddr(t *testing.T) { + t.Parallel() + loopback := []string{ + "127.0.0.1:6060", + "localhost:6060", + "[::1]:6060", + "127.0.0.5:6060", + } + nonLoopback := []string{ + ":6060", // wildcard + "0.0.0.0:6060", // v4 wildcard + "[::]:6060", // v6 wildcard + "192.168.1.5:6060", // LAN + "10.0.0.1:6060", // RFC 1918 + "example.com:6060", // unresolved hostname — fail-closed + "not-a-valid-addr", // SplitHostPort fails — fail-closed + } + for _, s := range loopback { + if !isLoopbackBindAddr(s) { + t.Errorf("isLoopbackBindAddr(%q) = false, want true", s) + } + } + for _, s := range nonLoopback { + if isLoopbackBindAddr(s) { + t.Errorf("isLoopbackBindAddr(%q) = true, want false", s) + } + } +}
cmd/arc/main.go+23 −2 modified@@ -177,6 +177,14 @@ func main() { // Initialize shutdown coordinator shutdownCoordinator := shutdown.New(30*time.Second, logger.Get("shutdown")) + // Opt-in pprof on a localhost listener (no-op unless ARC_DEBUG_PPROF=1). + // Replaces the previous behaviour where pprof was unconditionally + // mounted on the public Fiber app and any network-reachable caller + // could fetch heap dumps or pin a CPU core via /debug/pprof/profile. + // See cmd/arc/debug_pprof.go for the rationale. Closes audit #2 + // (GHSA-j93g-rp6m-j32m) from 2026-05-19. + startDebugPprofIfEnabled(shutdownCoordinator, logger.Get("debug-pprof")) + // arcx (Arc Enterprise DuckDB extension): gate via license before // passing the path down to the DB layer. The extension binary is the // licensing perimeter, but having Arc refuse to LOAD without a valid @@ -1400,7 +1408,12 @@ func main() { // without auth tokens after compaction. Access is gated by X-Arc-Internal header // validation in the handler. Cluster nodes should be on a private network. middlewareConfig.PublicRoutes = append(middlewareConfig.PublicRoutes, "/health", "/ready", "/api/v1/auth/verify", "/api/v1/internal/cache/invalidate") - middlewareConfig.PublicPrefixes = append(middlewareConfig.PublicPrefixes, "/metrics", "/debug/pprof") + // /metrics stays public — Prometheus scrapers expect it. It is + // already in auth.DefaultMiddlewareConfig().PublicPrefixes, so no + // further append is needed here. /debug/pprof is intentionally NOT + // here: pprof is no longer mounted on the public Fiber app + // (internal/api/server.go). The opt-in localhost pprof listener + // runs on a separate port; see startDebugPprofIfEnabled. server.GetApp().Use(auth.NewMiddleware(middlewareConfig)) // Initialize RBAC Manager (Enterprise feature) @@ -2062,7 +2075,15 @@ func main() { auditHandler.RegisterRoutes(server.GetApp()) } - // Register HTTP server shutdown hook (first to stop accepting new requests) + // Register HTTP server shutdown hook (first to stop accepting new + // requests). The 30-second arg is the HTTP server's INTERNAL drain + // timeout — independent of shutdownCoordinator's own 30-second + // budget. If the coordinator ctx expires before this hook completes, + // the coordinator skips remaining hooks; the internal timeout is a + // best-effort upper bound on this hook's slice of that budget. The + // debug-pprof hook (registered earlier in main, same priority) uses + // srv.Close() instead of Shutdown(ctx) to avoid letting a long + // /debug/pprof/profile?seconds=N capture starve this hook. shutdownCoordinator.RegisterHook("http-server", func(ctx context.Context) error { return server.Shutdown(30 * time.Second) }, shutdown.PriorityHTTPServer)
internal/api/server.go+6 −3 modified@@ -15,7 +15,6 @@ import ( "github.com/basekick-labs/arc/internal/metrics" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/gofiber/fiber/v2/middleware/pprof" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/rs/zerolog" ) @@ -127,8 +126,12 @@ func NewServer(config *ServerConfig, logger zerolog.Logger) *Server { // This prevents double-decompression issues with gzip-compressed MessagePack payloads // Response compression is still available via Accept-Encoding handling - // pprof profiling endpoints - app.Use(pprof.New()) + // pprof is NOT registered on the public Fiber app. It used to be — + // `app.Use(pprof.New())` — but that exposed `/debug/pprof/*` to any + // network-reachable caller (auth middleware short-circuited it via + // PublicPrefixes). See cmd/arc/main.go for the opt-in localhost-bound + // pprof listener started only when ARC_DEBUG_PPROF=1. + // Closes audit finding #2 from 2026-05-19 (GHSA-j93g-rp6m-j32m). // Request logging middleware app.Use(requestLogger(logger))
internal/api/server_pprof_test.go+63 −0 added@@ -0,0 +1,63 @@ +package api + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog" +) + +// TestServer_PprofNotRegisteredOnPublicApp is the regression test for audit +// finding #2 (GHSA-j93g-rp6m-j32m): the previous default mounted +// `/debug/pprof/*` on the public Fiber app and added `/debug/pprof` to +// PublicPrefixes, so any network-reachable caller could fetch heap dumps +// or pin a CPU core via `/debug/pprof/profile?seconds=30`. +// +// After the fix, the public Fiber app must NEVER serve `/debug/pprof/*`. +// pprof is opt-in via ARC_DEBUG_PPROF=1 and binds to a separate +// localhost-only listener — that's tested in cmd/arc/debug_pprof_test.go +// (the binary's integration surface). This test pins the library +// invariant: NewServer's app does not mount pprof handlers. +// +// Probed paths cover the surface gofiber/fiber/v2/middleware/pprof used +// to register: index, heap, profile, goroutine, allocs, mutex, block, +// trace, cmdline, symbol. Each must 404 — Fiber returns 404 for any +// route the app doesn't know about, including before the auth +// middleware fires, which is the security guarantee we want. +func TestServer_PprofNotRegisteredOnPublicApp(t *testing.T) { + t.Parallel() + srv := NewServer(DefaultServerConfig(), zerolog.Nop()) + app := srv.GetApp() + + pprofPaths := []string{ + "/debug/pprof/", + "/debug/pprof/heap", + "/debug/pprof/profile", + "/debug/pprof/profile?seconds=1", + "/debug/pprof/goroutine", + "/debug/pprof/allocs", + "/debug/pprof/mutex", + "/debug/pprof/block", + "/debug/pprof/threadcreate", + "/debug/pprof/trace", + "/debug/pprof/cmdline", + "/debug/pprof/symbol", + } + for _, p := range pprofPaths { + t.Run(p, func(t *testing.T) { + req := httptest.NewRequest("GET", p, nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + // Anything other than 404 means pprof is reachable on the + // public app — that's the bug. We accept 404; we reject 200, + // 500, 302, etc. (Fiber's default not-found returns 404.) + if resp.StatusCode != fiber.StatusNotFound { + t.Errorf("path %q: expected 404 (pprof must not be mounted), got %d", + p, resp.StatusCode) + } + }) + } +}
internal/auth/middleware.go+51 −4 modified@@ -1,6 +1,7 @@ package auth import ( + "path" "strings" "sync" @@ -51,15 +52,61 @@ func NewMiddleware(config MiddlewareConfig) fiber.Handler { return c.Next() } - // Check if route is public - path := c.Path() + // Normalize the request path before public-route / prefix matching. + // c.Path() is the raw fasthttp PathOriginal — Fiber does NOT + // resolve `..`, `.`, or `//` before handing it to middleware. Two + // concrete failure modes the un-normalized form admits: + // + // - `/metrics//foo` lexically starts with `/metrics/`, so the + // anchored HasPrefix below admits it; today it falls through + // to a Fiber 404 because the router also doesn't normalize, + // but the day someone wires up a wildcard route this becomes + // a real bypass. + // - `/metrics/../api/v1/query` lexically starts with `/metrics/` + // and the bypass branch fires; same dead-end on routing today, + // same future hazard. + // + // path.Clean collapses `//` into `/`, removes `/.` segments, and + // resolves `/..` against the preceding segment, so the matching + // below operates on the canonical form a downstream router would + // see. This is also belt-and-suspenders against the audit-log + // pollution surface — the audit middleware logs c.Path() verbatim, + // so an attacker who can pollute lookup with weird paths can also + // pollute the audit trail. + reqPath := path.Clean(c.Path()) for _, route := range config.PublicRoutes { - if path == route { + if reqPath == route { return c.Next() } } for _, prefix := range config.PublicPrefixes { - if strings.HasPrefix(path, prefix) { + // Normalise prefix: strip exactly one trailing slash. An + // operator who reads "this is a directory prefix" and writes + // `/metrics/` in their config would otherwise silently break + // the anchored match: prefix+"/" becomes "/metrics//" which + // matches no real path. Symmetric with the request-path + // normalisation above; both sides should be in canonical form + // before the equality and HasPrefix checks. We deliberately + // don't `path.Clean` the prefix because (a) it's + // operator-supplied static config, not attacker-controlled, + // and (b) Clean would also collapse a configured `//foo` to + // `/foo`, hiding the misconfiguration instead of treating it + // as the same prefix the operator wrote. + prefix = strings.TrimSuffix(prefix, "/") + // Guard against empty-prefix entries (including a configured + // `/` that the TrimSuffix above just emptied). `HasPrefix(p, "/")` + // is true for every real HTTP path, so an empty prefix would + // disable auth wholesale. + if prefix == "" { + continue + } + // Anchor the match: require exact path or true subdirectory + // (`prefix + "/"`). Without this, a configured prefix `/metrics` + // also matches `/metricsX`, `/metrics-secret`, etc. — any route + // happening to share the same byte prefix would silently bypass + // auth. Matches the same shape gemini-code-assist flagged on + // PR #442's deniedRoots check. + if reqPath == prefix || strings.HasPrefix(reqPath, prefix+"/") { return c.Next() } }
internal/auth/middleware_test.go+137 −0 modified@@ -226,6 +226,143 @@ func TestMiddleware_PublicPrefixes(t *testing.T) { } } +// TestMiddleware_PublicPrefixes_AnchoredMatch is the regression test for the +// gemini-flagged anchoring gap. Before the fix, strings.HasPrefix(path, "/metrics") +// would silently let /metricsX, /metrics-secret, /metricsadmin bypass auth — +// any route happening to share the byte prefix slipped through. The fix +// requires exact-equal or true-subdirectory (`prefix + "/"`) matching after +// path.Clean normalisation, so non-canonical shapes like `/metrics//foo`, +// `/metrics/./x`, and `/metrics/../X` cannot pollute the bypass branch +// either. +// +// We assert against status, not against registered handlers. Bypass paths +// (200 == handler ran) get a real handler; non-bypass paths (401 == auth +// returned without c.Next) do not — Fiber's middleware short-circuits +// before route lookup. +func TestMiddleware_PublicPrefixes_AnchoredMatch(t *testing.T) { + config := DefaultMiddlewareConfig() + // Inject an empty string and a non-empty prefix together. The empty + // entry tests the empty-prefix guard (must NOT make every request a + // bypass); the non-empty entry tests the anchored-match contract. + config.PublicPrefixes = []string{"", "/metrics"} + + _, app, cleanup := setupMiddlewareTest(t, config) + defer cleanup() + + // Only register the bypass paths — 401 paths short-circuit before + // route dispatch, so handlers aren't required. + for _, p := range []string{ + "/metrics", "/metrics/", "/metrics/prometheus", "/metrics/sub/leaf", + } { + app.Get(p, func(c *fiber.Ctx) error { return c.SendString("ok") }) + } + + tests := []struct { + name string + path string + expectedStatus int + why string + }{ + // Positive: must bypass. + {"exact match", "/metrics", fiber.StatusOK, "exact-equal must bypass"}, + {"trailing-slash match", "/metrics/", fiber.StatusOK, "prefix + '/' must bypass"}, + {"true subdirectory", "/metrics/prometheus", fiber.StatusOK, "true subdir must bypass"}, + {"deep subdirectory", "/metrics/sub/leaf", fiber.StatusOK, "deep subdir must bypass"}, + + // Negative: sibling shapes that share the prefix bytes but aren't subdirectories. + {"sibling path with same prefix bytes", "/metricsX", fiber.StatusUnauthorized, "/metricsX is NOT a subdir of /metrics"}, + {"sibling with separator-shaped suffix", "/metrics-secret", fiber.StatusUnauthorized, "/metrics-secret is NOT a subdir of /metrics"}, + {"sibling alphanum suffix", "/metricsadmin", fiber.StatusUnauthorized, "/metricsadmin is NOT a subdir of /metrics"}, + + // Negative: parent-traversal shapes that path.Clean must normalise + // AWAY from the /metrics tree before the bypass branch checks + // them. The middleware sees the normalised form and correctly + // rejects the request (401). We only test escape-the-prefix + // shapes here because non-escaping shapes like `/metrics//foo` and + // `/metrics/./foo` — while correctly bypassed by the middleware — + // hit a Fiber router that DOES NOT normalise, so they 404 instead + // of reaching the registered handler. Asserting 404 would couple + // the test to Fiber's routing behaviour; asserting NOT-401 (i.e. + // auth bypass happened) is what we actually care about, and the + // security-relevant assertion is that escape shapes return 401. + {"parent-traversal escapes the prefix", "/metrics/../sensitive", fiber.StatusUnauthorized, "after path.Clean → /sensitive, NOT under /metrics"}, + {"parent-traversal escapes via leading dot-dot", "/metrics/sub/../../etc", fiber.StatusUnauthorized, "after path.Clean → /etc, NOT under /metrics"}, + + // Empty-prefix guard: an empty entry in PublicPrefixes must NOT + // cause `/any-path` to be treated as bypass. If the guard breaks, + // /any-other-path would return 200 instead of 401. + {"empty prefix entry does not match unrelated paths", "/some/random/api", fiber.StatusUnauthorized, "empty-string prefix must be skipped, NOT match every path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + if resp.StatusCode != tt.expectedStatus { + t.Errorf("path %q: expected status %d, got %d (%s)", + tt.path, tt.expectedStatus, resp.StatusCode, tt.why) + } + }) + } +} + +// TestMiddleware_PublicPrefixes_TrailingSlashNormalisation is the +// regression test for the gemini-r2 finding: a configured prefix with a +// trailing slash (e.g. `/metrics/`) used to break the anchored match +// because `prefix + "/"` produced `/metrics//`, which lexically matches +// no real request path. The fix strips exactly one trailing slash from +// the prefix before the anchored check, so configured `/metrics/` and +// configured `/metrics` are byte-identical at match time. Test both +// shapes against the same set of request paths to pin that equivalence. +func TestMiddleware_PublicPrefixes_TrailingSlashNormalisation(t *testing.T) { + configs := []struct { + name string + prefix string + }{ + {"prefix without trailing slash", "/metrics"}, + {"prefix WITH trailing slash", "/metrics/"}, + } + for _, cfg := range configs { + cfg := cfg // capture for parallel subtests + t.Run(cfg.name, func(t *testing.T) { + mw := DefaultMiddlewareConfig() + mw.PublicPrefixes = []string{cfg.prefix} + + _, app, cleanup := setupMiddlewareTest(t, mw) + defer cleanup() + app.Get("/metrics", func(c *fiber.Ctx) error { return c.SendString("ok") }) + app.Get("/metrics/prometheus", func(c *fiber.Ctx) error { return c.SendString("ok") }) + + tests := []struct { + name string + path string + expectedStatus int + }{ + {"exact match", "/metrics", fiber.StatusOK}, + {"true subdirectory", "/metrics/prometheus", fiber.StatusOK}, + {"sibling byte-prefix", "/metricsX", fiber.StatusUnauthorized}, + {"parent traversal escape", "/metrics/../sensitive", fiber.StatusUnauthorized}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + if resp.StatusCode != tt.expectedStatus { + t.Errorf("prefix=%q path=%q: expected status %d, got %d", + cfg.prefix, tt.path, tt.expectedStatus, resp.StatusCode) + } + }) + } + }) + } +} + // TestMiddleware_NoToken tests request without any token func TestMiddleware_NoToken(t *testing.T) { config := DefaultMiddlewareConfig()
RELEASE_NOTES_2026.06.1.md+29 −0 modified@@ -35,6 +35,35 @@ The arcx loader was simplified: the previous per-connection `connInitFn` (which Tests added: `TestSandbox` (CVE reproduction + full I/O family + SSRF + `COPY TO` local + `COPY TO 's3://...'` + `EXPORT DATABASE` outside allowlist + `INSTALL` after lockdown + cross-connection enforcement + `range()` remains callable + lockdown is one-way), `TestBuildAllowedDirectories` (12 table cases covering hot/cold S3 dedup, leading/interior-slash collapse, trailing-slash idempotence, empty-config behavior), `TestSandboxEmptyAllowlistLogsButDoesNotPanic`. The existing arcx tests confirm `arcx_version()` and `SET GLOBAL arcx.storage_root` propagate across 3–4 concurrent pool connections. +### Go pprof endpoints no longer reachable from the public API port + +Reported by [Alex Manson](https://neurowinter.com/) ([@NeuroWinter](https://github.com/NeuroWinter)) — thank you for the detailed report. + +Pre-26.06.1, `internal/api/server.go` called `app.Use(pprof.New())` unconditionally on the public Fiber app, and `cmd/arc/main.go` added `/debug/pprof` to the auth middleware's `PublicPrefixes` list. The combined effect: any network-reachable caller — no token, no auth header, no anything — could fetch: + +- `GET /debug/pprof/heap` — leaks in-memory state (live SQL strings, decoded msgpack records, decompressed request bodies, cached `*TokenInfo` derived from plaintext-token hashes in the auth cache). +- `GET /debug/pprof/goroutine?debug=2` — leaks every goroutine's call stack, identifying internal code paths and surfaces. +- `GET /debug/pprof/profile?seconds=N` — pins a CPU core for arbitrary duration. One request = minutes of server CPU. Trivial DoS amplification. +- `GET /debug/pprof/trace?seconds=N` — same CPU-burn profile via a different handler. + +26.06.1 removes pprof from the public Fiber app entirely. The endpoints are now opt-in via `ARC_DEBUG_PPROF=1`, and when enabled they bind to a separate `127.0.0.1:6060` listener (override via `ARC_DEBUG_PPROF_ADDR`; a non-loopback bind additionally requires `ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK=1` so a single typo in the address knob cannot expose pprof to the network). The localhost listener registers its handlers on a private `*http.ServeMux`, NOT on `http.DefaultServeMux` — so even if a future PR adds an `http.Server` somewhere with `Handler: nil`, that server will not unintentionally serve pprof (verified at merge time that no such caller exists in the binary). The listener is wired into the existing shutdown coordinator at `PriorityHTTPServer` and shuts down via `srv.Close()` (force-close, not `srv.Shutdown`) so an in-flight long-running `/debug/pprof/profile?seconds=N` capture cannot pin the coordinator's shared 30-second shutdown budget and starve downstream shutdown hooks. Server-side timeouts (`ReadHeaderTimeout=5s`, `WriteTimeout=10m`, `IdleTimeout=60s`) bound slow-client attacks on the debug surface. + +Operator-facing changes: + +- **Default behavior changed**: pprof is no longer reachable on Arc's API port (`:8000` by default). Existing deployments that relied on `curl http://arc:8000/debug/pprof/heap` will start getting `404 Not Found`. Set `ARC_DEBUG_PPROF=1` and reach pprof on `127.0.0.1:6060` instead. +- **`/metrics` is unchanged**: Prometheus scrapers continue to work as before. Only `/debug/pprof/*` moved. +- **Non-loopback bind requires a second opt-in**: `ARC_DEBUG_PPROF_ADDR` accepts any bind string Go's `net.Listen` understands, but a non-loopback override (e.g. `0.0.0.0:6060`) additionally requires `ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK=1`. Without that second env var Arc logs an `Error` and refuses to start the pprof listener; with it, Arc logs an `Error` (not Warn) at startup naming the bound address so cross-host exposure shows up in default alerting policies. + +A **defense-in-depth fix to the auth middleware's `PublicPrefixes` matcher** is also included. The previous `strings.HasPrefix(path, prefix)` would match `/metrics` against `/metrics`, `/metrics/prometheus`, AND `/metricsX`, `/metrics-secret`, etc. — any byte-prefix match silently bypassed auth. Three changes: + +1. **Anchored prefix match**: the matcher now requires exact-equal or true-subdirectory (`prefix + "/"`); a sibling path with the same prefix bytes no longer slips through. Same shape as the prefix-match gap gemini-code-assist flagged on PR #442's `deniedRoots` check. +2. **Path normalisation before match**: the matcher runs `path.Clean(c.Path())` first, so non-canonical request shapes like `/metrics//foo`, `/metrics/./x`, `/metrics/../sensitive` are normalised to their canonical form before the bypass branch checks them. Without normalisation, an attacker-controlled `/metrics/../api/v1/query` lexically starts with `/metrics/` and would slip past the anchored check; after normalisation it becomes `/api/v1/query` and correctly requires auth. +3. **Empty-prefix guard**: an empty string in `PublicPrefixes` (no legitimate config has one, but a future bug — e.g. an env-var split producing an empty slice entry — could) would otherwise short-circuit every request because `HasPrefix(anyPath, "")` is true. The matcher now skips empty entries. + +With `/debug/pprof` removed from the public prefix list, items 1 and 2 are not currently reachable for any production route — the fixes are guards for any prefix added in the future. + +Tests added: `TestServer_PprofNotRegisteredOnPublicApp` (12 pprof paths against the public Fiber app, all must 404), `TestMiddleware_PublicPrefixes_AnchoredMatch` (10 subtests: exact match + trailing-slash match + true subdir + deep subdir bypass + 3 sibling-byte-prefix shapes that must require auth + 2 parent-traversal escape shapes that must require auth + empty-prefix guard), plus the new `cmd/arc/debug_pprof_test.go` (no-op when disabled, binds-and-serves when enabled, `isTruthy` env-var contract, `isLoopbackBindAddr` detection incl. fail-closed on unresolvable hosts). + ## Bug fixes ### Parser no longer mis-resolves bare `time` column inside `EXTRACT(YEAR FROM time)`
Vulnerability mechanics
Root cause
"Go's net/http/pprof handlers are registered on the public Fiber app and added to the auth middleware's PublicPrefixes list, causing the auth middleware to short-circuit before token verification and exposing debug endpoints without authentication."
Attack vector
Any network-reachable caller can send an HTTP request to `/debug/pprof/heap`, `/debug/pprof/goroutine?debug=2`, `/debug/pprof/profile?seconds=N`, or `/debug/pprof/trace` without providing any authentication token [ref_id=2][ref_id=3]. The auth middleware matches `/debug/pprof` against its `PublicPrefixes` list and short-circuits before the token check, so the pprof handlers execute without any credential validation [ref_id=2]. There is no rate limiting or resource bound on the `seconds` parameter, allowing trivial CPU-burn denial-of-service amplification [ref_id=2]. Additionally, the `HasPrefix` bug meant that paths like `/debug/pprofX` would also bypass authentication [ref_id=3].
Affected code
The vulnerability exists in `internal/api/server.go` where `app.Use(pprof.New())` registers Go's `net/http/pprof` handlers on the public Fiber app, and in `cmd/arc/main.go` where `/debug/pprof` is added to the `PublicPrefixes` list that causes the auth middleware to short-circuit before token verification [ref_id=2][ref_id=3]. The patch introduces `cmd/arc/debug_pprof.go` to gate pprof behind the `ARC_DEBUG_PPROF` env var and bind it to a separate localhost-only listener [patch_id=5618539].
What the fix does
The patch removes pprof from the public Fiber app entirely and introduces a new `startDebugPprofIfEnabled` function in `cmd/arc/debug_pprof.go` that only starts a pprof listener when the `ARC_DEBUG_PPROF` environment variable is explicitly set to a truthy value [patch_id=5618539]. When enabled, pprof binds to `127.0.0.1:6060` by default on a separate `net/http` server with its own private `*http.ServeMux`, making it unreachable from the public API port [patch_id=5618539]. A non-loopback bind address additionally requires `ARC_DEBUG_PPROF_ALLOW_NON_LOOPBACK=1` to prevent accidental network exposure [patch_id=5618539]. The auth middleware's `PublicPrefixes` matcher is also fixed to use anchored matching (exact-equal or true-subdirectory) instead of `strings.HasPrefix`, preventing sibling paths like `/debug/pprofX` from bypassing authentication [patch_id=5618539].
Preconditions
- networkThe attacker must be able to reach the Arc API port over the network
- authNo authentication token is required
Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.