From e0cba8f415283d7416445f0d8c4125f0ca38679b Mon Sep 17 00:00:00 2001 From: yusing Date: Thu, 9 Apr 2026 18:24:15 +0800 Subject: [PATCH] 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. --- .env.example | 6 ++- internal/common/env.go | 1 + internal/config/local_api_test.go | 77 +++++++++++++++++++++++++++++++ internal/config/state.go | 54 ++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 internal/config/local_api_test.go diff --git a/.env.example b/.env.example index b2129c4f..ffa0f295 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +GODOXY_DEBUG=false diff --git a/internal/common/env.go b/internal/common/env.go index 172f45df..9af50e0c 100644 --- a/internal/common/env.go +++ b/internal/common/env.go @@ -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", "")) diff --git a/internal/config/local_api_test.go b/internal/config/local_api_test.go new file mode 100644 index 00000000..92aaef71 --- /dev/null +++ b/internal/config/local_api_test.go @@ -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) + } + }) + } +} diff --git a/internal/config/state.go b/internal/config/state.go index 22c7f2d7..1b0bbbb2 100644 --- a/internal/config/state.go +++ b/internal/config/state.go @@ -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)