feat(web): harden launcher access control

This commit is contained in:
lc6464
2026-06-09 18:16:14 +08:00
committed by Guoguo
parent f8462855d8
commit 52ab6c4694
18 changed files with 475 additions and 68 deletions
+4
View File
@@ -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
+33 -13
View File
@@ -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...),
})
}
+106 -1
View File
@@ -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())
}
}
+29 -21
View File
@@ -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) {
+10 -1
View File
@@ -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 {
+44 -1
View File
@@ -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"}
+9 -1
View File
@@ -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)
}
+73 -23
View File
@@ -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")
+89 -6
View File
@@ -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")
}
}
+2
View File
@@ -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 {
@@ -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: "",
}
@@ -1243,6 +1243,34 @@ export function LauncherSection({
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
/>
</Field>
<SwitchCardField
label={t("pages.config.allow_localhost_bypass")}
hint={t("pages.config.allow_localhost_bypass_hint")}
layout="setting-row"
checked={launcherForm.allowLocalhostBypass}
disabled={disabled}
onCheckedChange={(checked) =>
onFieldChange("allowLocalhostBypass", checked)
}
/>
<Field
label={t("pages.config.trusted_proxy_cidrs")}
hint={t("pages.config.trusted_proxy_cidrs_hint")}
layout="setting-row"
controlClassName="md:max-w-md"
>
<Textarea
value={launcherForm.trustedProxyCIDRsText}
disabled={disabled}
placeholder={t("pages.config.trusted_proxy_cidrs_placeholder")}
className="min-h-[88px]"
onChange={(e) =>
onFieldChange("trustedProxyCIDRsText", e.target.value)
}
/>
</Field>
</ConfigSectionCard>
)
}
@@ -74,6 +74,8 @@ export interface LauncherForm {
port: string
publicAccess: boolean
allowedCIDRsText: string
allowLocalhostBypass: boolean
trustedProxyCIDRsText: string
dashboardPassword: string
dashboardPasswordConfirm: string
}
@@ -163,6 +165,8 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800",
publicAccess: false,
allowedCIDRsText: "",
allowLocalhostBypass: true,
trustedProxyCIDRsText: "",
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
+5
View File
@@ -849,6 +849,11 @@
"allowed_cidrs": "অনুমোদিত নেটওয়ার্ক CIDR",
"allowed_cidrs_hint": "শুধুমাত্র এই CIDR পরিসরের ক্লায়েন্টরা সার্ভিস অ্যাক্সেস করতে পারে। প্রতি লাইনে একটি বা কমা দিয়ে আলাদা। সবাইকে অনুমতি দিতে খালি রাখুন।",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"allow_localhost_bypass": "Localhost bypass অনুমতি দিন",
"allow_localhost_bypass_hint": "সক্ষম থাকলে, localhost অনুরোধ অনুমোদিত CIDR না মিললেও অনুমতি পাবে। একই হোস্টের proxy এর পেছনে launcher থাকলে এটি বন্ধ করুন।",
"trusted_proxy_cidrs": "বিশ্বস্ত proxy CIDR",
"trusted_proxy_cidrs_hint": "শুধুমাত্র এই CIDR পরিসরের proxy X-Forwarded-For-এর মতো client IP তথ্য পাঠাতে পারে। proxy client IP header উপেক্ষা করতে খালি রাখুন।",
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
"evolution_section_hint": "এজেন্টকে সম্পন্ন টার্ন থেকে শিখতে দিন এবং দক্ষতার উন্নতি প্রস্তুত করুন।",
"evolution_enabled": "ইভোলিউশন সক্ষম করুন",
"evolution_enabled_hint": "সম্পন্ন টার্নের জন্য শেখার ডেটা রেকর্ড করুন। ড্রাফ্ট এবং অ্যাপ্লাই মোড দক্ষতা আপডেটও তৈরি করতে পারে।",
+5
View File
@@ -848,6 +848,11 @@
"allowed_cidrs": "Povolené síťové CIDRy",
"allowed_cidrs_hint": "Ke službě mají přístup pouze klienti z těchto CIDR rozsahů. Jeden na řádek nebo oddělené čárkou. Ponechte prázdné pro povolení všech.",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"allow_localhost_bypass": "Povolit obcházení přes localhost",
"allow_localhost_bypass_hint": "Když je zapnuto, požadavky z localhostu jsou povoleny i mimo povolené CIDRy. Vypněte, pokud je launcher za proxy na stejném hostu.",
"trusted_proxy_cidrs": "Důvěryhodné CIDRy proxy",
"trusted_proxy_cidrs_hint": "Pouze proxy z těchto CIDR rozsahů mohou předávat informace o IP klienta, například X-Forwarded-For. Ponechte prázdné pro ignorování proxy hlaviček s IP klienta.",
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
"evolution_section_hint": "Nechte agenta učit se z dokončených tahů a připravovat vylepšení dovedností.",
"evolution_enabled": "Povolit evoluci",
"evolution_enabled_hint": "Zaznamenávat data učení pro dokončené tahy. Režimy Návrh a Aplikovat mohou také generovat aktualizace dovedností.",
+5
View File
@@ -850,6 +850,11 @@
"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_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"allow_localhost_bypass": "Allow Localhost Bypass",
"allow_localhost_bypass_hint": "When enabled, localhost requests are allowed even when they do not match the allowed CIDRs. Disable this when the launcher is behind a same-host proxy.",
"trusted_proxy_cidrs": "Trusted Proxy CIDRs",
"trusted_proxy_cidrs_hint": "Only proxies from these CIDR ranges may provide client IP information such as X-Forwarded-For. Leave empty to ignore proxy client IP headers.",
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
"evolution_section_hint": "Let the agent learn from completed turns and prepare skill improvements.",
"evolution_enabled": "Enable Evolution",
"evolution_enabled_hint": "Record learning data for completed turns. Draft and apply modes can also generate skill updates.",
+5
View File
@@ -850,6 +850,11 @@
"allowed_cidrs": "CIDRs de Rede Permitidos",
"allowed_cidrs_hint": "Apenas clientes destes intervalos CIDR podem acessar o serviço. Um por linha ou separados por vírgula. Deixe vazio para permitir todos.",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"allow_localhost_bypass": "Permitir Bypass por Localhost",
"allow_localhost_bypass_hint": "Quando habilitado, requisições localhost são permitidas mesmo sem corresponder aos CIDRs permitidos. Desabilite quando o launcher estiver atrás de um proxy no mesmo host.",
"trusted_proxy_cidrs": "CIDRs de Proxy Confiáveis",
"trusted_proxy_cidrs_hint": "Apenas proxies destes intervalos CIDR podem informar dados do IP do cliente, como X-Forwarded-For. Deixe vazio para ignorar cabeçalhos de IP do cliente vindos de proxy.",
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
"evolution_section_hint": "Permite que o agente aprenda com turnos concluídos e prepare melhorias de skills.",
"evolution_enabled": "Habilitar Evolução",
"evolution_enabled_hint": "Registrar dados de aprendizado para turnos concluídos. Os modos Draft e Apply também podem gerar atualizações de skills.",
+5
View File
@@ -850,6 +850,11 @@
"allowed_cidrs": "允许访问网段",
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"allow_localhost_bypass": "允许 localhost 绕过",
"allow_localhost_bypass_hint": "启用后,即使 localhost 请求不匹配允许访问网段,也会被放行。如果启动器位于同主机代理之后,建议关闭。",
"trusted_proxy_cidrs": "可信代理网段",
"trusted_proxy_cidrs_hint": "仅这些 CIDR 网段内的代理可以传递客户端 IP 信息,例如 X-Forwarded-For。留空表示忽略代理传递的客户端 IP 头。",
"trusted_proxy_cidrs_placeholder": "172.16.0.0/12\n127.0.0.1/32",
"evolution_section_hint": "让 Agent 从已完成的回合中学习,并准备技能改进。",
"evolution_enabled": "启用自进化",
"evolution_enabled_hint": "记录已完成回合的学习数据。Draft 和 Apply 模式还可以生成技能更新。",