root 6af0520ed2 A: fail-loud on non-loopback bind — closes worst case of R-001
shared.Run now refuses to bind a non-loopback address unless the
LH_<SERVICE>_ALLOW_NONLOOPBACK=1 env is set. Single change covers
all 7 binaries via the existing Run call site; no per-binary
wiring needed.

Closes the accidental-0.0.0.0 deploy attack surface for R-001:
queryd /sql is RCE-equivalent off loopback (DuckDB has filesystem
read + COPY TO + read_text), but the gate applies to every binary
uniformly so the same posture covers vectord (mutation routes),
catalogd (manifest writes), and the others.

What passes the gate:
  127.0.0.1:port, 127.x.y.z:port (full /8), [::1]:port,
  localhost:port, OR explicit env LH_<SVC>_ALLOW_NONLOOPBACK=1

What fail-louds:
  0.0.0.0:port, [::]:port, :port (all interfaces),
  any non-loopback IP, any non-localhost hostname,
  unparseable shapes ("", "no port", garbage)

Override env is strict equality "1" — typos like "true"/"yes" do NOT
trigger it, so a future operator can't accidentally expose by typing
the wrong value. Override fires log a structured warn so the choice
is auditable in production.

Error message cites the env name AND R-001 by name so operators see
the fix path without grepping:
  "refusing non-loopback bind \"0.0.0.0:3214\" for \"queryd\"
   (set LH_QUERYD_ALLOW_NONLOOPBACK=1 to override; see audit R-001)"

internal/shared/bind.go            — requireLoopbackOrOverride + isLoopbackAddr
internal/shared/bind_test.go       — 7 test funcs incl. table-driven
                                     IPv4/IPv6/hostname coverage and
                                     per-service env isolation
internal/shared/server.go          — 1-line gate in Run before listen

Verified:
  go test -short ./internal/shared/ — all green (was 14 funcs, now 21)
  just verify                       — vet + test + 9 smokes still 33s

Doesn't address R-001's full attack surface (any reachable port can
issue arbitrary SQL); ADR-003 + Bearer-token middleware is the
follow-up. This commit makes the implicit "localhost-only is the auth
layer" guarantee explicit and un-bypassable without explicit env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 05:56:42 -05:00

76 lines
2.3 KiB
Go

package shared
import (
"fmt"
"log/slog"
"net"
"os"
"strings"
)
// requireLoopbackOrOverride enforces that the bind address is on the
// loopback interface unless an explicit env override is set. Closes
// the worst case of audit risk R-001 (queryd /sql + DuckDB + non-
// loopback bind = RCE-equivalent for anyone who can reach the port)
// without committing to an auth model.
//
// Override env: LH_<UPPER(serviceName)>_ALLOW_NONLOOPBACK=1.
// When the override fires, we log a structured warn so the choice is
// auditable in production logs.
//
// Cases that pass:
// - 127.0.0.1, 127.x.y.z (the /8), [::1], localhost
// - explicit-override env set to "1"
//
// Cases that fail-loud:
// - 0.0.0.0, [::], any non-loopback IP
// - empty host ":port" (listens on all interfaces)
// - unparseable addr
//
// The function is also useful as a unit-testable predicate; callers
// that want to gate something other than Run can call it directly.
func requireLoopbackOrOverride(serviceName, addr string) error {
if isLoopbackAddr(addr) {
return nil
}
envKey := "LH_" + strings.ToUpper(serviceName) + "_ALLOW_NONLOOPBACK"
if os.Getenv(envKey) == "1" {
slog.Warn("non-loopback bind allowed by env override",
"service", serviceName,
"addr", addr,
"env", envKey,
"hint", "audit risk R-001 — see reports/scrum/risk-register.md")
return nil
}
return fmt.Errorf("refusing non-loopback bind %q for %q "+
"(set %s=1 to override; see audit R-001)", addr, serviceName, envKey)
}
// isLoopbackAddr returns true iff addr's host portion is on the
// loopback interface. Covers IPv4 127.0.0.0/8, IPv6 ::1, and
// "localhost". Empty host (":port"), empty string, and any
// non-parseable addr return false.
func isLoopbackAddr(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
// Unparseable shape — could be a bare hostname or wholly
// malformed. Rejecting protects against future changes that
// silently accept new shapes.
return false
}
if host == "" {
// ":port" listens on ALL interfaces — explicitly non-loopback.
return false
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
if ip == nil {
// Hostname that isn't "localhost". We don't resolve DNS here
// (slow + misleading); reject so deploys must be explicit.
return false
}
return ip.IsLoopback()
}