VYPR
High severity8.8GHSA Advisory· Published Jun 11, 2026· Updated Jun 11, 2026

Arc: Unauthenticated access to Go debug pprof endpoints leaks runtime state and enables CPU-burn DoS

CVE-2026-48050

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

Patches

2
32a4091fb949

fix(security): gate pprof behind ARC_DEBUG_PPROF on a localhost listener + anchor + normalise PublicPrefixes match (#443)

https://github.com/Basekick-Labs/arcIgnacio Van DroogenbroeckMay 20, 2026via ghsa
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)`
    
32a4091fb949

fix(security): gate pprof behind ARC_DEBUG_PPROF on a localhost listener + anchor + normalise PublicPrefixes match (#443)

https://github.com/Basekick-Labs/arcIgnacio Van DroogenbroeckMay 20, 2026via text-mined
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

4

News mentions

0

No linked articles in our index yet.