From 0570099e0b702794ecb068a9d8a24e9deb220df2 Mon Sep 17 00:00:00 2001 From: duomi Date: Sun, 15 Mar 2026 19:04:02 +0800 Subject: [PATCH] fix(web): require CIDR allowlist for public mode --- README.md | 1 + web/backend/api/gateway_host_test.go | 5 +++-- web/backend/api/launcher_config_test.go | 24 +++++++++++++++++++++++ web/backend/api/startup_test.go | 5 +++-- web/backend/launcherconfig/config.go | 20 ++++++++++++++----- web/backend/launcherconfig/config_test.go | 19 ++++++++++++++++++ web/backend/main.go | 12 ++++++++++-- web/frontend/src/i18n/locales/en.json | 2 +- web/frontend/src/i18n/locales/zh.json | 2 +- 9 files changed, 77 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e64daf0e4..412c415f2 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ Open http://localhost:18800 in your browser. The launcher manages the gateway pr > [!WARNING] > The web console does not yet support authentication. Avoid exposing it to the public internet. +> If you enable LAN/public access, configure `allowed_cidrs` first. PicoClaw Web now refuses to start in public mode without an explicit CIDR allowlist. ### Agent Mode (One-shot) diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index afd600359..8140d97e3 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -31,8 +31,9 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") launcherPath := launcherconfig.PathForAppConfig(configPath) if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ - Port: 18800, - Public: true, + Port: 18800, + Public: true, + AllowedCIDRs: []string{"192.168.1.0/24"}, }); err != nil { t.Fatalf("launcherconfig.Save() error = %v", err) } diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go index 0d6af823c..f52c5b186 100644 --- a/web/backend/api/launcher_config_test.go +++ b/web/backend/api/launcher_config_test.go @@ -113,3 +113,27 @@ func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } } + +func TestPutLauncherConfigRejectsPublicWithoutCIDRs(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":true,"allowed_cidrs":[]}`), + ) + 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()) + } + if !strings.Contains(rec.Body.String(), "allowed_cidrs") { + t.Fatalf("body = %q, want allowed_cidrs error", rec.Body.String()) + } +} diff --git a/web/backend/api/startup_test.go b/web/backend/api/startup_test.go index cfa9b4c53..e3853b263 100644 --- a/web/backend/api/startup_test.go +++ b/web/backend/api/startup_test.go @@ -16,8 +16,9 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) { // pin them into autostart args. launcherPath := launcherconfig.PathForAppConfig(configPath) if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ - Port: 19999, - Public: true, + Port: 19999, + Public: true, + AllowedCIDRs: []string{"192.168.1.0/24"}, }); err != nil { t.Fatalf("launcherconfig.Save() error = %v", err) } diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index 4dca45b0e..dc725f1a1 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -28,12 +28,14 @@ func Default() Config { return Config{Port: DefaultPort, Public: false} } -// Validate checks if launcher settings are valid. -func Validate(cfg Config) error { - if cfg.Port < 1 || cfg.Port > 65535 { - return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port) +// ValidateNetworkExposure ensures public mode is only enabled with an +// explicit CIDR allowlist and that each CIDR is syntactically valid. +func ValidateNetworkExposure(public bool, allowedCIDRs []string) error { + normalized := NormalizeCIDRs(allowedCIDRs) + if public && len(normalized) == 0 { + return fmt.Errorf("public mode requires at least one allowed_cidrs entry") } - for _, cidr := range cfg.AllowedCIDRs { + for _, cidr := range normalized { if _, _, err := net.ParseCIDR(cidr); err != nil { return fmt.Errorf("invalid CIDR %q", cidr) } @@ -41,6 +43,14 @@ func Validate(cfg Config) error { return nil } +// Validate checks if launcher settings are valid. +func Validate(cfg Config) error { + if cfg.Port < 1 || cfg.Port > 65535 { + return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port) + } + return ValidateNetworkExposure(cfg.Public, cfg.AllowedCIDRs) +} + // NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. func NormalizeCIDRs(cidrs []string) []string { if len(cidrs) == 0 { diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index c63bee09a..7f26cbc85 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -75,6 +75,25 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } +func TestValidateRejectsPublicWithoutCIDRs(t *testing.T) { + err := Validate(Config{Port: 18800, Public: true}) + if err == nil { + t.Fatal("Validate() expected error for public mode without allowed_cidrs") + } +} + +func TestValidateNetworkExposure_AllowsLocalhostWithoutCIDRs(t *testing.T) { + if err := ValidateNetworkExposure(false, nil); err != nil { + t.Fatalf("ValidateNetworkExposure() error = %v, want nil", err) + } +} + +func TestValidateNetworkExposure_AllowsPublicWithCIDRs(t *testing.T) { + if err := ValidateNetworkExposure(true, []string{"192.168.1.0/24"}); err != nil { + t.Fatalf("ValidateNetworkExposure() error = %v, want nil", err) + } +} + 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 650540ea8..e60aa4b80 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -30,7 +30,11 @@ import ( func main() { port := flag.String("port", "18800", "Port to listen on") - public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") + public := flag.Bool( + "public", + false, + "Listen on all interfaces (0.0.0.0) instead of localhost only; requires allowed_cidrs in launcher-config.json", + ) noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup") flag.Usage = func() { @@ -45,7 +49,7 @@ func main() { fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0]) fmt.Fprintf( os.Stderr, - " %s -public ./config.json Allow access from other devices on the network\n", + " %s -public ./config.json Allow access from other devices on the network (requires allowed_cidrs)\n", os.Args[0], ) } @@ -101,6 +105,10 @@ func main() { log.Fatalf("Invalid port %q: %v", effectivePort, err) } + if err := launcherconfig.ValidateNetworkExposure(effectivePublic, launcherCfg.AllowedCIDRs); err != nil { + log.Fatalf("Invalid network exposure configuration: %v", err) + } + // Determine listen address var addr string if effectivePublic { diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index b099dec13..c8defe860 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -429,7 +429,7 @@ "lan_access": "Enable LAN Access", "lan_access_hint": "Allow access from other devices on your local network.", "allowed_cidrs": "Allowed Network CIDRs", - "allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.", + "allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Required when LAN access is enabled; leave empty only for localhost-only mode.", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", "sections": { "agent": "Agent", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 78093e5c7..a483b68a9 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -429,7 +429,7 @@ "lan_access": "启用局域网访问", "lan_access_hint": "允许局域网中的其他设备访问当前服务。", "allowed_cidrs": "允许访问网段", - "allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。", + "allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;启用局域网访问时必须填写,只有本地 localhost 模式才可留空。", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", "sections": { "agent": "智能体",