fix(web): require CIDR allowlist for public mode
This commit is contained in:
parent
0f700a6bf0
commit
0570099e0b
9 changed files with 77 additions and 13 deletions
|
|
@ -207,6 +207,7 @@ Open http://localhost:18800 in your browser. The launcher manages the gateway pr
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
> 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)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
|
||||||
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
||||||
Port: 18800,
|
Port: 18800,
|
||||||
Public: true,
|
Public: true,
|
||||||
|
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("launcherconfig.Save() error = %v", err)
|
t.Fatalf("launcherconfig.Save() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,3 +113,27 @@ func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) {
|
||||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
|
||||||
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
||||||
Port: 19999,
|
Port: 19999,
|
||||||
Public: true,
|
Public: true,
|
||||||
|
AllowedCIDRs: []string{"192.168.1.0/24"},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("launcherconfig.Save() error = %v", err)
|
t.Fatalf("launcherconfig.Save() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,14 @@ func Default() Config {
|
||||||
return Config{Port: DefaultPort, Public: false}
|
return Config{Port: DefaultPort, Public: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if launcher settings are valid.
|
// ValidateNetworkExposure ensures public mode is only enabled with an
|
||||||
func Validate(cfg Config) error {
|
// explicit CIDR allowlist and that each CIDR is syntactically valid.
|
||||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
func ValidateNetworkExposure(public bool, allowedCIDRs []string) error {
|
||||||
return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port)
|
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 {
|
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||||
return fmt.Errorf("invalid CIDR %q", cidr)
|
return fmt.Errorf("invalid CIDR %q", cidr)
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +43,14 @@ func Validate(cfg Config) error {
|
||||||
return nil
|
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.
|
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
|
||||||
func NormalizeCIDRs(cidrs []string) []string {
|
func NormalizeCIDRs(cidrs []string) []string {
|
||||||
if len(cidrs) == 0 {
|
if len(cidrs) == 0 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestNormalizeCIDRs(t *testing.T) {
|
||||||
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
|
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"}
|
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,11 @@ import (
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := flag.String("port", "18800", "Port to listen on")
|
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")
|
noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup")
|
||||||
|
|
||||||
flag.Usage = func() {
|
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 ./config.json Specify a config file\n", os.Args[0])
|
||||||
fmt.Fprintf(
|
fmt.Fprintf(
|
||||||
os.Stderr,
|
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],
|
os.Args[0],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +105,10 @@ func main() {
|
||||||
log.Fatalf("Invalid port %q: %v", effectivePort, err)
|
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
|
// Determine listen address
|
||||||
var addr string
|
var addr string
|
||||||
if effectivePublic {
|
if effectivePublic {
|
||||||
|
|
|
||||||
|
|
@ -429,7 +429,7 @@
|
||||||
"lan_access": "Enable LAN Access",
|
"lan_access": "Enable LAN Access",
|
||||||
"lan_access_hint": "Allow access from other devices on your local network.",
|
"lan_access_hint": "Allow access from other devices on your local network.",
|
||||||
"allowed_cidrs": "Allowed Network CIDRs",
|
"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",
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
"sections": {
|
"sections": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|
|
||||||
|
|
@ -429,7 +429,7 @@
|
||||||
"lan_access": "启用局域网访问",
|
"lan_access": "启用局域网访问",
|
||||||
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
|
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
|
||||||
"allowed_cidrs": "允许访问网段",
|
"allowed_cidrs": "允许访问网段",
|
||||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;启用局域网访问时必须填写,只有本地 localhost 模式才可留空。",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
"sections": {
|
"sections": {
|
||||||
"agent": "智能体",
|
"agent": "智能体",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue