fix(web): require CIDR allowlist for public mode

This commit is contained in:
duomi 2026-03-15 19:04:02 +08:00
parent 0f700a6bf0
commit 0570099e0b
9 changed files with 77 additions and 13 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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())
}
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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"}

View file

@ -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 {

View file

@ -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",

View file

@ -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": "智能体",