diff --git a/web/README.md b/web/README.md index 774ad8f5d..b0463de01 100644 --- a/web/README.md +++ b/web/README.md @@ -81,6 +81,8 @@ That file currently stores: - `port` - `public` - `allowed_cidrs` +- `allow_localhost_bypass` +- `trusted_proxy_cidrs` If `-port` or `-public` are passed explicitly, the CLI flag wins for that run. If they are omitted, stored launcher settings are used. @@ -152,6 +154,8 @@ When public access is enabled: - the launcher still protects the dashboard with password login - optional `allowed_cidrs` can restrict which client IP ranges may connect +- `allow_localhost_bypass` defaults to `true`; set it to `false` when same-host proxies or tunnels should not bypass `allowed_cidrs` +- optional `trusted_proxy_cidrs` can trust specific reverse proxies to supply the original client IP through headers such as `X-Forwarded-For` - the gateway host is overridden so remote clients can still use the launcher-managed proxy paths ## Build And Run diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go index 92911157c..1e1de9f6d 100644 --- a/web/backend/api/launcher_config.go +++ b/web/backend/api/launcher_config.go @@ -9,9 +9,19 @@ import ( ) type launcherConfigPayload struct { - Port int `json:"port"` - Public bool `json:"public"` - AllowedCIDRs []string `json:"allowed_cidrs"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs"` + AllowLocalhostBypass bool `json:"allow_localhost_bypass"` + TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"` +} + +type launcherConfigUpdatePayload struct { + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs"` + AllowLocalhostBypass *bool `json:"allow_localhost_bypass"` + TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"` } func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { @@ -29,9 +39,11 @@ func (h *Handler) launcherFallbackConfig() launcherconfig.Config { port = launcherconfig.DefaultPort } return launcherconfig.Config{ - Port: port, - Public: h.serverPublic, - AllowedCIDRs: append([]string(nil), h.serverCIDRs...), + Port: port, + Public: h.serverPublic, + AllowedCIDRs: append([]string(nil), h.serverCIDRs...), + AllowLocalhostBypass: h.serverAllowLocalhostBypass, + TrustedProxyCIDRs: append([]string(nil), h.serverTrustedProxyCIDRs...), } } @@ -48,14 +60,16 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ - Port: cfg.Port, - Public: cfg.Public, - AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + AllowLocalhostBypass: cfg.AllowLocalhostBypass, + TrustedProxyCIDRs: append([]string(nil), cfg.TrustedProxyCIDRs...), }) } func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) { - var payload launcherConfigPayload + var payload launcherConfigUpdatePayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return @@ -69,6 +83,10 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ cfg.Port = payload.Port cfg.Public = payload.Public cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...) + if payload.AllowLocalhostBypass != nil { + cfg.AllowLocalhostBypass = *payload.AllowLocalhostBypass + } + cfg.TrustedProxyCIDRs = append([]string(nil), payload.TrustedProxyCIDRs...) cfg.LegacyLauncherToken = "" if err := launcherconfig.Validate(cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -82,8 +100,10 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ - Port: cfg.Port, - Public: cfg.Public, - AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + AllowLocalhostBypass: cfg.AllowLocalhostBypass, + TrustedProxyCIDRs: append([]string(nil), cfg.TrustedProxyCIDRs...), }) } diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go index 68ab1be42..43af60688 100644 --- a/web/backend/api/launcher_config_test.go +++ b/web/backend/api/launcher_config_test.go @@ -16,6 +16,7 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"}) + h.SetServerAccessOptions(false, []string{"10.0.0.0/8"}) mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -38,6 +39,12 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs) } + if got.AllowLocalhostBypass { + t.Fatalf("response allow_localhost_bypass = true, want false") + } + if len(got.TrustedProxyCIDRs) != 1 || got.TrustedProxyCIDRs[0] != "10.0.0.0/8" { + t.Fatalf("response trusted_proxy_cidrs = %v, want [10.0.0.0/8]", got.TrustedProxyCIDRs) + } } func TestPutLauncherConfigPersists(t *testing.T) { @@ -60,7 +67,7 @@ func TestPutLauncherConfigPersists(t *testing.T) { http.MethodPut, "/api/system/launcher-config", strings.NewReader( - `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`, + `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"allow_localhost_bypass":false,"trusted_proxy_cidrs":["10.0.0.0/8"]}`, ), ) req.Header.Set("Content-Type", "application/json") @@ -86,6 +93,83 @@ func TestPutLauncherConfigPersists(t *testing.T) { if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs) } + if cfg.AllowLocalhostBypass { + t.Fatalf("saved config allow_localhost_bypass = true, want false") + } + if len(cfg.TrustedProxyCIDRs) != 1 || cfg.TrustedProxyCIDRs[0] != "10.0.0.0/8" { + t.Fatalf("saved config trusted_proxy_cidrs = %v, want [10.0.0.0/8]", cfg.TrustedProxyCIDRs) + } +} + +func TestPutLauncherConfigUsesDirectAccessFields(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader( + `{"port":18080,"public":false,"allowed_cidrs":["192.168.1.0/24"],"allow_localhost_bypass":true,"trusted_proxy_cidrs":["10.0.0.0/8"]}`, + ), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := launcherconfig.Load(launcherconfig.PathForAppConfig(configPath), launcherconfig.Default()) + if err != nil { + t.Fatalf("launcherconfig.Load() error = %v", err) + } + if !cfg.AllowLocalhostBypass { + t.Fatal("saved config allow_localhost_bypass = false, want true") + } + if len(cfg.TrustedProxyCIDRs) != 1 || cfg.TrustedProxyCIDRs[0] != "10.0.0.0/8" { + t.Fatalf("saved config trusted_proxy_cidrs = %v, want [10.0.0.0/8]", cfg.TrustedProxyCIDRs) + } +} + +func TestPutLauncherConfigKeepsLocalhostBypassWhenOmitted(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + path := launcherconfig.PathForAppConfig(configPath) + if err := os.WriteFile( + path, + []byte(`{"port":18800,"public":false,"allow_localhost_bypass":true,"trusted_proxy_cidrs":["10.0.0.0/8"]}`), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := launcherconfig.Load(path, launcherconfig.Default()) + if err != nil { + t.Fatalf("launcherconfig.Load() error = %v", err) + } + if !cfg.AllowLocalhostBypass { + t.Fatal("saved config allow_localhost_bypass = false, want true") + } } func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) { @@ -129,3 +213,24 @@ func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } } + +func TestPutLauncherConfigRejectsInvalidTrustedProxyCIDR(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader(`{"port":18080,"public":false,"trusted_proxy_cidrs":["bad-cidr"]}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 76f63607e..abd3196c3 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -10,32 +10,35 @@ import ( // Handler serves HTTP API requests. type Handler struct { - configPath string - serverPort int - serverPublic bool - serverPublicExplicit bool - serverHostInput string - serverHostExplicit bool - serverCIDRs []string - debug bool - oauthMu sync.Mutex - oauthFlows map[string]*oauthFlow - oauthState map[string]string - weixinMu sync.Mutex - weixinFlows map[string]*weixinFlow - wecomMu sync.Mutex - wecomFlows map[string]*wecomFlow + configPath string + serverPort int + serverPublic bool + serverPublicExplicit bool + serverHostInput string + serverHostExplicit bool + serverCIDRs []string + serverAllowLocalhostBypass bool + serverTrustedProxyCIDRs []string + debug bool + oauthMu sync.Mutex + oauthFlows map[string]*oauthFlow + oauthState map[string]string + weixinMu sync.Mutex + weixinFlows map[string]*weixinFlow + wecomMu sync.Mutex + wecomFlows map[string]*wecomFlow } // NewHandler creates an instance of the API handler. func NewHandler(configPath string) *Handler { return &Handler{ - configPath: configPath, - serverPort: launcherconfig.DefaultPort, - oauthFlows: make(map[string]*oauthFlow), - oauthState: make(map[string]string), - weixinFlows: make(map[string]*weixinFlow), - wecomFlows: make(map[string]*wecomFlow), + configPath: configPath, + serverPort: launcherconfig.DefaultPort, + serverAllowLocalhostBypass: launcherconfig.Default().AllowLocalhostBypass, + oauthFlows: make(map[string]*oauthFlow), + oauthState: make(map[string]string), + weixinFlows: make(map[string]*weixinFlow), + wecomFlows: make(map[string]*wecomFlow), } } @@ -49,6 +52,11 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a h.serverCIDRs = append([]string(nil), allowedCIDRs...) } +func (h *Handler) SetServerAccessOptions(allowLocalhostBypass bool, trustedProxyCIDRs []string) { + h.serverAllowLocalhostBypass = allowLocalhostBypass + h.serverTrustedProxyCIDRs = append([]string(nil), trustedProxyCIDRs...) +} + // SetServerBindHost stores the launcher's effective bind host. // When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value. func (h *Handler) SetServerBindHost(hostInput string, explicit bool) { diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index e3595738f..3873af4fa 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -23,6 +23,8 @@ type Config struct { Port int `json:"port"` Public bool `json:"public"` AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + AllowLocalhostBypass bool `json:"allow_localhost_bypass"` + TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"` DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"` // LegacyLauncherToken is read only for one-time migration from the removed // token login flow. Save always clears it so new configs do not persist it. @@ -31,7 +33,7 @@ type Config struct { // Default returns default launcher settings. func Default() Config { - return Config{Port: DefaultPort, Public: false} + return Config{Port: DefaultPort, Public: false, AllowLocalhostBypass: true} } // Validate checks if launcher settings are valid. @@ -44,6 +46,11 @@ func Validate(cfg Config) error { return fmt.Errorf("invalid CIDR %q", cidr) } } + for _, cidr := range cfg.TrustedProxyCIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return fmt.Errorf("invalid trusted proxy CIDR %q", cidr) + } + } return nil } @@ -95,6 +102,7 @@ func Load(path string, fallback Config) (Config, error) { return Config{}, err } cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) + cfg.TrustedProxyCIDRs = NormalizeCIDRs(cfg.TrustedProxyCIDRs) cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken) if err := Validate(cfg); err != nil { @@ -106,6 +114,7 @@ func Load(path string, fallback Config) (Config, error) { // Save writes launcher settings to disk. func Save(path string, cfg Config) error { cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) + cfg.TrustedProxyCIDRs = NormalizeCIDRs(cfg.TrustedProxyCIDRs) cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) cfg.LegacyLauncherToken = "" if err := Validate(cfg); err != nil { diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index bb13ea115..83dbea37e 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -9,7 +9,9 @@ import ( func TestLoadReturnsFallbackWhenMissing(t *testing.T) { path := filepath.Join(t.TempDir(), "launcher-config.json") - fallback := Config{Port: 19999, Public: true} + fallback := Default() + fallback.Port = 19999 + fallback.Public = true got, err := Load(path, fallback) if err != nil { @@ -18,6 +20,9 @@ func TestLoadReturnsFallbackWhenMissing(t *testing.T) { if got.Port != fallback.Port || got.Public != fallback.Public { t.Fatalf("Load() = %+v, want %+v", got, fallback) } + if !got.AllowLocalhostBypass { + t.Fatal("allow_localhost_bypass = false, want true") + } } func TestSaveAndLoadRoundTrip(t *testing.T) { @@ -27,6 +32,8 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { Port: 18080, Public: true, AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, + AllowLocalhostBypass: false, + TrustedProxyCIDRs: []string{"172.16.0.0/12"}, DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash", LegacyLauncherToken: "legacy-token-should-not-persist", } @@ -41,6 +48,9 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { if got.Port != want.Port || got.Public != want.Public { t.Fatalf("Load() = %+v, want %+v", got, want) } + if got.AllowLocalhostBypass != want.AllowLocalhostBypass { + t.Fatalf("allow_localhost_bypass = %t, want %t", got.AllowLocalhostBypass, want.AllowLocalhostBypass) + } if got.DashboardPasswordHash != want.DashboardPasswordHash { t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash) } @@ -55,6 +65,14 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i]) } } + if len(got.TrustedProxyCIDRs) != len(want.TrustedProxyCIDRs) { + t.Fatalf("trusted_proxy_cidrs len = %d, want %d", len(got.TrustedProxyCIDRs), len(want.TrustedProxyCIDRs)) + } + for i := range want.TrustedProxyCIDRs { + if got.TrustedProxyCIDRs[i] != want.TrustedProxyCIDRs[i] { + t.Fatalf("trusted_proxy_cidrs[%d] = %q, want %q", i, got.TrustedProxyCIDRs[i], want.TrustedProxyCIDRs[i]) + } + } stat, err := os.Stat(path) if err != nil { @@ -80,6 +98,21 @@ func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) { } } +func TestLoadDefaultsAllowLocalhostBypassForLegacyConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + if err := os.WriteFile(path, []byte(`{"port":18800,"allowed_cidrs":["10.0.0.0/8"]}`), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + got, err := Load(path, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !got.AllowLocalhostBypass { + t.Fatal("allow_localhost_bypass = false, want true for legacy config") + } +} + func TestValidateRejectsInvalidPort(t *testing.T) { if err := Validate(Config{Port: 0, Public: false}); err == nil { t.Fatal("Validate() expected error for port 0") @@ -99,6 +132,16 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } +func TestValidateRejectsInvalidTrustedProxyCIDR(t *testing.T) { + err := Validate(Config{ + Port: 18800, + TrustedProxyCIDRs: []string{"not-a-cidr"}, + }) + if err == nil { + t.Fatal("Validate() expected error for invalid trusted proxy CIDR") + } +} + func TestNormalizeCIDRs(t *testing.T) { got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) want := []string{"192.168.1.0/24", "10.0.0.0/8"} diff --git a/web/backend/main.go b/web/backend/main.go index fa2448d5c..400902c56 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -585,13 +585,21 @@ func main() { logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) } apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + apiHandler.SetServerAccessOptions( + launcherCfg.AllowLocalhostBypass, + launcherCfg.TrustedProxyCIDRs, + ) apiHandler.SetServerBindHost(hostInput, hostOverrideActive) apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets registerEmbedRoutes(mux) - accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux) + accessControlledMux, err := middleware.IPAllowlist(middleware.IPAllowlistConfig{ + AllowedCIDRs: launcherCfg.AllowedCIDRs, + AllowLocalhostBypass: launcherCfg.AllowLocalhostBypass, + TrustedProxyCIDRs: launcherCfg.TrustedProxyCIDRs, + }, mux) if err != nil { logger.Fatalf("Invalid allowed CIDR configuration: %v", err) } diff --git a/web/backend/middleware/access_control.go b/web/backend/middleware/access_control.go index 159d60c3e..86bd0ca06 100644 --- a/web/backend/middleware/access_control.go +++ b/web/backend/middleware/access_control.go @@ -7,42 +7,77 @@ import ( "strings" ) +// IPAllowlistConfig controls launcher network access decisions. +type IPAllowlistConfig struct { + AllowedCIDRs []string + AllowLocalhostBypass bool + TrustedProxyCIDRs []string +} + // IPAllowlist restricts access to requests from configured CIDR ranges. -// Loopback addresses are always allowed for local administration. +// Loopback addresses can optionally bypass CIDR checks for local administration. +// X-Forwarded-For is only trusted when the immediate peer is in a trusted CIDR. // Empty CIDR list means no restriction. -func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) { - if len(allowedCIDRs) == 0 { +func IPAllowlist(cfg IPAllowlistConfig, next http.Handler) (http.Handler, error) { + allowedNets, err := parseCIDRNets(cfg.AllowedCIDRs) + if err != nil { + return nil, err + } + trustedProxyNets, err := parseCIDRNets(cfg.TrustedProxyCIDRs) + if err != nil { + return nil, err + } + + if len(allowedNets) == 0 { return next, nil } - nets := make([]*net.IPNet, 0, len(allowedCIDRs)) - for _, cidr := range allowedCIDRs { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + peerIP := clientIPFromRemoteAddr(r.RemoteAddr) + if peerIP == nil { + rejectByPolicy(w, r) + return + } + + ip := peerIP + if containsIP(trustedProxyNets, peerIP) { + if forwardedIP := clientIPFromXForwardedFor(r.Header.Get("X-Forwarded-For")); forwardedIP != nil { + ip = forwardedIP + } + } + + if cfg.AllowLocalhostBypass && ip.IsLoopback() { + next.ServeHTTP(w, r) + return + } + if containsIP(allowedNets, ip) { + next.ServeHTTP(w, r) + return + } + + rejectByPolicy(w, r) + }), nil +} + +func parseCIDRNets(cidrs []string) ([]*net.IPNet, error) { + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { _, ipNet, err := net.ParseCIDR(cidr) if err != nil { return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) } nets = append(nets, ipNet) } + return nets, nil +} - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := clientIPFromRemoteAddr(r.RemoteAddr) - if ip == nil { - rejectByPolicy(w, r) - return +func containsIP(nets []*net.IPNet, ip net.IP) bool { + for _, ipNet := range nets { + if ipNet.Contains(ip) { + return true } - if ip.IsLoopback() { - next.ServeHTTP(w, r) - return - } - for _, ipNet := range nets { - if ipNet.Contains(ip) { - next.ServeHTTP(w, r) - return - } - } - - rejectByPolicy(w, r) - }), nil + } + return false } func clientIPFromRemoteAddr(remoteAddr string) net.IP { @@ -53,6 +88,21 @@ func clientIPFromRemoteAddr(remoteAddr string) net.IP { return net.ParseIP(host) } +func clientIPFromXForwardedFor(header string) net.IP { + first, _, _ := strings.Cut(header, ",") + first = strings.Trim(strings.TrimSpace(first), `"`) + if first == "" { + return nil + } + if ip := net.ParseIP(first); ip != nil { + return ip + } + if host, _, err := net.SplitHostPort(first); err == nil { + return net.ParseIP(host) + } + return nil +} + func rejectByPolicy(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Type", "application/json") diff --git a/web/backend/middleware/access_control_test.go b/web/backend/middleware/access_control_test.go index 259fd4a4c..c0364e489 100644 --- a/web/backend/middleware/access_control_test.go +++ b/web/backend/middleware/access_control_test.go @@ -7,7 +7,7 @@ import ( ) func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) { - h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h, err := IPAllowlist(IPAllowlistConfig{}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) if err != nil { @@ -25,7 +25,9 @@ func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) { } func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) { - h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h, err := IPAllowlist(IPAllowlistConfig{ + AllowedCIDRs: []string{"192.168.1.0/24"}, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) if err != nil { @@ -43,7 +45,9 @@ func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) { } func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) { - h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h, err := IPAllowlist(IPAllowlistConfig{ + AllowedCIDRs: []string{"192.168.1.0/24"}, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) if err != nil { @@ -60,8 +64,11 @@ func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) { } } -func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) { - h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestIPAllowlist_AllowsLoopbackWhenBypassEnabled(t *testing.T) { + h, err := IPAllowlist(IPAllowlistConfig{ + AllowedCIDRs: []string{"192.168.1.0/24"}, + AllowLocalhostBypass: true, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) if err != nil { @@ -78,9 +85,85 @@ func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) { } } +func TestIPAllowlist_RejectsLoopbackWhenBypassDisabled(t *testing.T) { + h, err := IPAllowlist(IPAllowlistConfig{ + AllowedCIDRs: []string{"192.168.1.0/24"}, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "127.0.0.1:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestIPAllowlist_IgnoresXForwardedForFromUntrustedPeer(t *testing.T) { + h, err := IPAllowlist(IPAllowlistConfig{ + AllowedCIDRs: []string{"192.168.1.0/24"}, + TrustedProxyCIDRs: []string{"10.0.0.0/8"}, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "203.0.113.5:1234" + req.Header.Set("X-Forwarded-For", "192.168.1.88") + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestIPAllowlist_UsesXForwardedForFromTrustedPeer(t *testing.T) { + h, err := IPAllowlist(IPAllowlistConfig{ + AllowedCIDRs: []string{"192.168.1.0/24"}, + TrustedProxyCIDRs: []string{"10.0.0.0/8"}, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.8:1234" + req.Header.Set("X-Forwarded-For", "192.168.1.88, 203.0.113.5") + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + func TestIPAllowlist_InvalidCIDR(t *testing.T) { - _, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + _, err := IPAllowlist(IPAllowlistConfig{ + AllowedCIDRs: []string{"bad-cidr"}, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) if err == nil { t.Fatal("IPAllowlist() expected error for invalid CIDR") } } + +func TestIPAllowlist_InvalidTrustedProxyCIDR(t *testing.T) { + _, err := IPAllowlist(IPAllowlistConfig{ + AllowedCIDRs: []string{"192.168.1.0/24"}, + TrustedProxyCIDRs: []string{"bad-cidr"}, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + if err == nil { + t.Fatal("IPAllowlist() expected error for invalid trusted proxy CIDR") + } +} diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index dfc48b6b8..55376cc2c 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -11,6 +11,8 @@ export interface LauncherConfig { port: number public: boolean allowed_cidrs: string[] + allow_localhost_bypass: boolean + trusted_proxy_cidrs: string[] } export interface SystemVersionInfo { diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index b37877260..e0174e2c7 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -165,6 +165,10 @@ export function ConfigPage() { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), + allowLocalhostBypass: launcherConfig.allow_localhost_bypass ?? true, + trustedProxyCIDRsText: (launcherConfig.trusted_proxy_cidrs ?? []).join( + "\n", + ), dashboardPassword: "", dashboardPasswordConfirm: "", } @@ -182,7 +186,11 @@ export function ConfigPage() { const launcherSettingsDirty = launcherForm.port !== launcherBaseline.port || launcherForm.publicAccess !== launcherBaseline.publicAccess || - launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText + launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText || + launcherForm.allowLocalhostBypass !== + launcherBaseline.allowLocalhostBypass || + launcherForm.trustedProxyCIDRsText !== + launcherBaseline.trustedProxyCIDRsText const launcherPasswordDirty = launcherForm.dashboardPassword.trim() !== "" || launcherForm.dashboardPasswordConfirm.trim() !== "" @@ -637,10 +645,15 @@ export function ConfigPage() { max: 65535, }) const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText) + const trustedProxyCIDRs = parseCIDRText( + launcherForm.trustedProxyCIDRsText, + ) const savedLauncherConfig = await updateLauncherConfig({ port, public: launcherForm.publicAccess, allowed_cidrs: allowedCIDRs, + allow_localhost_bypass: launcherForm.allowLocalhostBypass, + trusted_proxy_cidrs: trustedProxyCIDRs, }) const parsedLauncher: LauncherForm = { port: String(savedLauncherConfig.port), @@ -648,6 +661,11 @@ export function ConfigPage() { allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), + allowLocalhostBypass: + savedLauncherConfig.allow_localhost_bypass ?? true, + trustedProxyCIDRsText: ( + savedLauncherConfig.trusted_proxy_cidrs ?? [] + ).join("\n"), dashboardPassword: "", dashboardPasswordConfirm: "", } diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 75baa397c..a921b998f 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -1243,6 +1243,34 @@ export function LauncherSection({ onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)} /> + + + onFieldChange("allowLocalhostBypass", checked) + } + /> + + +