fix(P09): daemon auth hardening (REQ-123, REQ-124, F6, F24)
---ci--- project: orca phase: 9 milestone: v0.12 status: execute ---/ci--- - Start() refuses plaintext mode (mTLS required, R-021/REQ-123). - bodyLimitMiddleware wraps all handlers with MaxBytesReader (1 MiB, REQ-124/F24). - pprof loopback-only (isLoopback check; non-loopback refused with clear error, REQ-123). 2 new pprof loopback tests + existing daemon tests pass. Full build + vet green.
This commit is contained in:
@@ -2,16 +2,46 @@ package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isLoopback reports whether the address binds to a loopback interface
|
||||
// (127.0.0.1, ::1, localhost). REQ-123: pprof must be loopback-only.
|
||||
func isLoopback(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func StartPprof(addr string, log *slog.Logger) (*http.Server, error) {
|
||||
if addr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// REQ-123: pprof must bind to loopback only. Non-loopback addresses
|
||||
// require explicit --pprof-allow-public confirmation (which the CLI
|
||||
// passes after a warning). We refuse non-loopback here by default.
|
||||
if !isLoopback(addr) {
|
||||
log.Error("pprof refuses non-loopback bind",
|
||||
slog.String("addr", addr),
|
||||
slog.String("reason", "REQ-123: pprof is unauthenticated; use --pprof-allow-public to override (operator-only)"))
|
||||
return nil, fmt.Errorf("pprof: refusing non-loopback bind %s (REQ-123; unauthenticated; use --pprof-allow-public)", addr)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
|
||||
@@ -261,3 +261,28 @@ func TestServer_WithPprof(t *testing.T) {
|
||||
t.Error("expected main GET to fail after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// --- REQ-123 pprof loopback-only test ---
|
||||
|
||||
// TestStartPprof_NonLoopbackRefused verifies pprof refuses non-loopback.
|
||||
func TestStartPprof_NonLoopbackRefused(t *testing.T) {
|
||||
_, err := StartPprof("0.0.0.0:6060", slog.Default())
|
||||
if err == nil {
|
||||
t.Error("StartPprof on 0.0.0.0 should be refused (REQ-123)")
|
||||
}
|
||||
_, err = StartPprof("10.0.0.1:6060", slog.Default())
|
||||
if err == nil {
|
||||
t.Error("StartPprof on 10.0.0.1 should be refused (REQ-123)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartPprof_LoopbackAccepted verifies loopback addresses are accepted.
|
||||
func TestStartPprof_LoopbackAccepted(t *testing.T) {
|
||||
srv, err := StartPprof("127.0.0.1:0", slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("StartPprof on 127.0.0.1 should be accepted: %v", err)
|
||||
}
|
||||
if srv != nil {
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
@@ -64,6 +65,19 @@ type Options struct {
|
||||
PprofAddr string
|
||||
}
|
||||
|
||||
// maxBodyBytes is the limit for request bodies on JSON-decoding
|
||||
// endpoints (REQ-124, F24). 1 MiB is generous for orca API calls.
|
||||
const maxBodyBytes int64 = 1 << 20
|
||||
|
||||
// bodyLimitMiddleware wraps the handler with a MaxBytesReader so
|
||||
// oversized request bodies are rejected before decoding (REQ-124).
|
||||
func bodyLimitMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// NewServer constructs a Server with the default mux and route table.
|
||||
func NewServer(opts Options) *Server {
|
||||
if opts.Log == nil {
|
||||
@@ -132,7 +146,7 @@ func (s *Server) mux() http.Handler {
|
||||
if s.dispatch != nil {
|
||||
s.dispatch.Mount(mux)
|
||||
}
|
||||
return loggingMiddleware(s.log, mux)
|
||||
return bodyLimitMiddleware(loggingMiddleware(s.log, mux))
|
||||
}
|
||||
|
||||
// RegisterDispatch attaches the orca.v1.Dispatch service to the
|
||||
@@ -151,8 +165,16 @@ func (s *Server) RegisterDispatch(h *DispatchHandlers) {
|
||||
}
|
||||
|
||||
// Start runs the HTTP server. Returns http.ErrServerClosed on clean shutdown.
|
||||
// R-021 / REQ-123: the daemon MUST run in mTLS mode (no plaintext).
|
||||
// If StartMTLS has not been called, Start refuses to run.
|
||||
func (s *Server) Start() error {
|
||||
s.log.Info("daemon starting",
|
||||
if s.mtls == nil {
|
||||
s.log.Error("daemon refuses to start in plaintext mode",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("reason", "mTLS is required (R-021, REQ-123); call StartMTLS first"))
|
||||
return fmt.Errorf("daemon: mTLS is required (R-021, REQ-123); refusing to start in plaintext mode")
|
||||
}
|
||||
s.log.Info("daemon starting (mTLS required)",
|
||||
slog.String("addr", s.addr),
|
||||
slog.String("component", "daemon"))
|
||||
return s.httpServer.ListenAndServe()
|
||||
|
||||
Reference in New Issue
Block a user