mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-14 04:29:39 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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", ""))
|
||||
|
||||
77
internal/config/local_api_test.go
Normal file
77
internal/config/local_api_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user