From 3e85d9ec8e81abe134ee106e500fe1e44d0a8dde Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 5 Sep 2026 12:48:50 -0700 Subject: [PATCH] admin: bind to loopback by default, guard public unauthenticated bind (#11185) admin: bind to loopback by default, refuse public unauthenticated bind The admin HTTP server (port 23646) defaulted to binding 0.0.0.0 with authentication disabled when -adminPassword was not supplied, exposing the full admin REST API (user creation, credential issuance, bucket deletion, filer deletion) unauthenticated on the network. This is the footgun described in GHSA-m3m8-mrgq-hf9h. Keep the no-auth mode for local dev, but remove the network exposure: - Add -ip flag (default 127.0.0.1) so the server binds loopback only unless the operator explicitly chooses a public address. - Refuse to start when binding a non-loopback address with no -adminPassword and no [https.admin] mTLS. The operator must enable auth or use loopback. - weed mini sets -ip from its existing -ip.bind; the guard does not apply because mini calls startAdminServer directly, not runAdmin. Addresses GHSA-m3m8-mrgq-hf9h. --- weed/command/admin.go | 52 ++++++++++++++++++++++++++++++++++++++----- weed/command/mini.go | 4 ++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/weed/command/admin.go b/weed/command/admin.go index 3454374be..2add286be 100644 --- a/weed/command/admin.go +++ b/weed/command/admin.go @@ -42,6 +42,7 @@ var ( type AdminOptions struct { port *int grpcPort *int + ip *string master *string masters *string // deprecated, for backward compatibility filerGroup *string @@ -75,6 +76,7 @@ func init() { cmdAdmin.Run = runAdmin // break init cycle a.port = cmdAdmin.Flag.Int("port", 23646, "admin server port") a.grpcPort = cmdAdmin.Flag.Int("port.grpc", 0, "gRPC server port for worker connections (default: http port + 10000)") + a.ip = cmdAdmin.Flag.String("ip", "127.0.0.1", "ip address to listen on. Default is loopback; set to 0.0.0.0 to listen on all interfaces (requires -adminPassword or [https.admin] mTLS in security.toml).") a.master = cmdAdmin.Flag.String("master", "localhost:9333", "comma-separated master servers") a.masters = cmdAdmin.Flag.String("masters", "", "comma-separated master servers (deprecated, use -master instead)") a.filerGroup = cmdAdmin.Flag.String("filerGroup", "", "filerGroup for the filers, brokers, and S3 servers") @@ -137,6 +139,13 @@ var cmdAdmin = &Command{ WEED_ADMIN_USER, WEED_ADMIN_PASSWORD, WEED_ADMIN_READONLY_USER, WEED_ADMIN_READONLY_PASSWORD - Precedence: CLI flag > env var / security.toml > default value + Network Binding: + - By default the admin server binds to 127.0.0.1 (loopback only). + - Use -ip=0.0.0.0 to listen on all interfaces. + - When binding to a non-loopback address, authentication MUST be enabled + (-adminPassword) or mTLS configured ([https.admin] key and ca in security.toml). + Otherwise the server refuses to start. + Security Configuration: - The admin server reads TLS configuration from security.toml - Configure [https.admin] section in security.toml for HTTPS support @@ -273,12 +282,30 @@ func runAdmin(cmd *Command, args []string) bool { *a.grpcPort = *a.port + 10000 } + // Security validation: refuse to bind a non-loopback address without + // authentication or mTLS. This prevents accidental exposure of the + // unauthenticated admin REST API on the network. Server-only TLS + // (https.admin.key without ca) encrypts transport but does not authenticate + // clients, so it is not sufficient — the operator must also set a password + // or configure mTLS (both key and ca). + hasMTLS := viper.GetString("https.admin.key") != "" && viper.GetString("https.admin.ca") != "" + if !isLoopbackIp(*a.ip) && *a.adminPassword == "" && !hasMTLS { + fmt.Printf("Error: the admin server is configured to bind to %s (non-loopback) with\n", *a.ip) + fmt.Printf(" authentication disabled. This would expose the admin API unauthenticated\n") + fmt.Printf(" on the network.\n") + fmt.Printf(" To fix this, either:\n") + fmt.Printf(" - set -adminPassword to enable authentication, or\n") + fmt.Printf(" - configure [https.admin] key and ca in security.toml for mTLS, or\n") + fmt.Printf(" - set -ip=127.0.0.1 to bind to loopback only.\n") + return false + } + // Security warnings - if *a.adminPassword == "" { + if *a.adminPassword == "" && isLoopbackIp(*a.ip) { fmt.Println("WARNING: Admin interface is running without authentication!") fmt.Println(" Set -adminPassword for production use") } - fmt.Printf("Starting SeaweedFS Admin Interface on port %d\n", *a.port) + fmt.Printf("Starting SeaweedFS Admin Interface on %s\n", util.JoinHostPort(*a.ip, *a.port)) fmt.Printf("Worker gRPC server will run on port %d\n", *a.grpcPort) fmt.Printf("Masters: %s\n", *a.master) fmt.Printf("Filers will be discovered automatically from masters\n") @@ -438,7 +465,7 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, adminHandlers.SetupRoutes(r, authRequired, *options.adminUser, *options.adminPassword, *options.readOnlyUser, *options.readOnlyPassword, enableUI) // Server configuration - addr := fmt.Sprintf(":%d", *options.port) + addr := util.JoinHostPort(*options.ip, *options.port) var handler http.Handler = r if urlPrefix != "" { stripped := http.StripPrefix(urlPrefix, r) @@ -505,10 +532,10 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, // and not forwarded. serveErrCh := make(chan error, 1) go func() { - glog.Infof("Starting SeaweedFS Admin Server on port %d", *options.port) + glog.Infof("Starting SeaweedFS Admin Server on %s", addr) var serveErr error if useTLS { - glog.Infof("Starting SeaweedFS Admin Server with TLS on port %d", *options.port) + glog.Infof("Starting SeaweedFS Admin Server with TLS on %s", addr) serveErr = server.ListenAndServeTLS("", "") } else { serveErr = server.ListenAndServe() @@ -702,3 +729,18 @@ func applyViperFallback(cmd *Command, flagPtr *string, flagName, viperKey string } } } + +// isLoopbackIp reports whether the given bind address is loopback. +// An empty string or "0.0.0.0" / "::" is treated as non-loopback (all +// interfaces), since those expose the server to the network. +func isLoopbackIp(ip string) bool { + if ip == "" { + return false + } + parsed := net.ParseIP(ip) + if parsed == nil { + // Unresolved hostname — treat as non-loopback to be safe. + return false + } + return parsed.IsLoopback() +} diff --git a/weed/command/mini.go b/weed/command/mini.go index 29cb36e58..146ad5ee5 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -1272,6 +1272,10 @@ func runMini(cmd *Command, args []string) bool { miniOptions.v.bindIp = miniBindIp miniOptions.v.masters = pb.ServerAddresses(actualPeersForComponents).ToAddresses() miniOptions.v.idleConnectionTimeout = miniTimeout + // Admin server binds to the same address as the other mini services. + // The public-bind-without-auth guard in `weed admin` does not apply here + // because mini calls startAdminServer directly, not runAdmin. + miniAdminOptions.ip = miniBindIp miniOptions.v.dataCenter = miniDataCenter miniOptions.v.rack = miniRack