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>
76 lines
2.3 KiB
Go
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()
|
|
}
|