feat(config): opt-in flag for non-loopback local API bind

Validate GODOXY_LOCAL_API_ADDR before starting the unauthenticated local
API. Loopback listeners still succeed by default; addresses that bind
all interfaces, unspecified IPs, LAN hosts, or non-loopback names need
GODOXY_LOCAL_API_ALLOW_NON_LOOPBACK=true.

When that opt-in is set and the host is not loopback, log a warning so
non-local exposure is obvious. Wire common.LocalAPIAllowNonLoopback from
LOCAL_API_ALLOW_NON_LOOPBACK and document it (with a risk note) in
.env.example.

Add TestValidateLocalAPIAddr for loopback, wildcard, LAN, and hostname
cases with the allow flag on and off.
This commit is contained in:
yusing
2026-04-09 18:24:15 +08:00
parent 361189118d
commit e0cba8f415
4 changed files with 137 additions and 1 deletions

View File

@@ -58,8 +58,12 @@ GODOXY_API_ADDR=127.0.0.1:8888
# Local API listening address (unauthenticated, optional)
# Useful for local development, debugging or automation
# Must bind to loopback only (localhost / 127.0.0.1 / ::1) unless GODOXY_LOCAL_API_ALLOW_NON_LOOPBACK is true
GODOXY_LOCAL_API_ADDR=
# WARNING: exposing local API to either LAN or WAN is dangerous, do not enable this unless you know what you're doing
GODOXY_LOCAL_API_ALLOW_NON_LOOPBACK=false
# Metrics
GODOXY_METRICS_DISABLE_CPU=false
GODOXY_METRICS_DISABLE_MEMORY=false
@@ -76,4 +80,4 @@ DOCKER_SOCKET=/var/run/docker.sock
SOCKET_PROXY_LISTEN_ADDR=127.0.0.1:2375
# Debug mode
GODOXY_DEBUG=false
GODOXY_DEBUG=false

View File

@@ -36,6 +36,7 @@ var (
LocalAPIHTTPHost,
LocalAPIHTTPPort,
LocalAPIHTTPURL = env.GetAddrEnv("LOCAL_API_ADDR", "", "http")
LocalAPIAllowNonLoopback = env.GetEnvBool("LOCAL_API_ALLOW_NON_LOOPBACK", false)
APIJWTSecure = env.GetEnvBool("API_JWT_SECURE", true)
APIJWTSecret = decodeJWTKey(env.GetEnvString("API_JWT_SECRET", ""))

View File

@@ -0,0 +1,77 @@
package config
import "testing"
func TestValidateLocalAPIAddr(t *testing.T) {
tests := []struct {
name string
addr string
allowNonLoopback bool
wantErr bool
}{
{
name: "localhost",
addr: "localhost:8888",
},
{
name: "ipv4_loopback",
addr: "127.0.0.1:8888",
},
{
name: "ipv6_loopback",
addr: "[::1]:8888",
},
{
name: "all_interfaces",
addr: ":8888",
wantErr: true,
},
{
name: "all_interfaces_allowed",
addr: ":8888",
allowNonLoopback: true,
},
{
name: "ipv4_unspecified",
addr: "0.0.0.0:8888",
wantErr: true,
},
{
name: "ipv4_unspecified_allowed",
addr: "0.0.0.0:8888",
allowNonLoopback: true,
},
{
name: "lan_ip",
addr: "192.168.1.10:8888",
wantErr: true,
},
{
name: "lan_ip_allowed",
addr: "192.168.1.10:8888",
allowNonLoopback: true,
},
{
name: "hostname_not_loopback",
addr: "godoxy.internal:8888",
wantErr: true,
},
{
name: "hostname_not_loopback_allowed",
addr: "godoxy.internal:8888",
allowNonLoopback: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateLocalAPIAddr(tt.addr, tt.allowNonLoopback)
if tt.wantErr && err == nil {
t.Fatalf("expected error for %q", tt.addr)
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error for %q: %v", tt.addr, err)
}
})
}
}

View File

@@ -216,6 +216,15 @@ func (state *state) StartAPIServers() {
// Local API Handler is used for unauthenticated access.
if common.LocalAPIHTTPAddr != "" {
if err := validateLocalAPIAddr(common.LocalAPIHTTPAddr, common.LocalAPIAllowNonLoopback); err != nil {
log.Err(err).Str("addr", common.LocalAPIHTTPAddr).Msg("refusing to start local API server")
return
}
if common.LocalAPIAllowNonLoopback && !isLoopbackLocalAPIHost(common.LocalAPIHTTPAddr) {
log.Warn().
Str("addr", common.LocalAPIHTTPAddr).
Msg("local API server is allowed to bind to non-loopback addresses")
}
_, err := server.StartServer(state.task.Subtask("local_api_server", false), server.Options{
Name: "local_api",
HTTPAddr: common.LocalAPIHTTPAddr,
@@ -227,6 +236,51 @@ func (state *state) StartAPIServers() {
}
}
func validateLocalAPIAddr(addr string, allowNonLoopback bool) error {
if isLoopbackLocalAPIHost(addr) {
return nil
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return err
}
if allowNonLoopback {
return nil
}
switch strings.ToLower(host) {
case "localhost":
return nil
case "":
return errors.New("local API address must bind to a loopback host, not all interfaces")
}
ip, err := netip.ParseAddr(host)
if err != nil {
return fmt.Errorf("local API address must use a loopback host: %w", err)
}
if !ip.IsLoopback() {
return fmt.Errorf("local API address must bind to a loopback host, got %q", host)
}
return nil
}
func isLoopbackLocalAPIHost(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
ip, err := netip.ParseAddr(host)
return err == nil && ip.IsLoopback()
}
func (state *state) StartMetrics() {
systeminfo.Poller.Start(state.task)
uptime.Poller.Start(state.task)