diff --git a/workspace-server/cmd/server/bind_test.go b/workspace-server/cmd/server/bind_test.go new file mode 100644 index 00000000..cb3dbe28 --- /dev/null +++ b/workspace-server/cmd/server/bind_test.go @@ -0,0 +1,89 @@ +package main + +import "testing" + +// TestResolveBindHost pins the precedence: BIND_ADDR explicit > dev-mode +// fail-open default of 127.0.0.1 > production-shape empty (all interfaces). +// +// Mutation-test invariant: removing the IsDevModeFailOpen() branch makes +// "no_bindaddr_devmode_unset_admin" fail (returns "" instead of "127.0.0.1"). +// Removing the BIND_ADDR branch makes "explicit_bindaddr_*" cases fail. +func TestResolveBindHost(t *testing.T) { + cases := []struct { + name string + bindAddr string + adminToken string + molEnv string + want string + }{ + { + name: "no_bindaddr_devmode_unset_admin", + bindAddr: "", + adminToken: "", + molEnv: "dev", + want: "127.0.0.1", + }, + { + name: "no_bindaddr_devmode_unset_admin_full_word", + bindAddr: "", + adminToken: "", + molEnv: "development", + want: "127.0.0.1", + }, + { + name: "no_bindaddr_admin_set_in_dev_env", + bindAddr: "", + adminToken: "secret", + molEnv: "dev", + want: "", // ADMIN_TOKEN flips IsDevModeFailOpen to false → all interfaces + }, + { + name: "no_bindaddr_production_env", + bindAddr: "", + adminToken: "", + molEnv: "production", + want: "", // production is not a dev value → all interfaces + }, + { + name: "no_bindaddr_unset_env", + bindAddr: "", + adminToken: "", + molEnv: "", + want: "", // unset MOLECULE_ENV → not dev → all interfaces + }, + { + name: "explicit_bindaddr_loopback_overrides_devmode", + bindAddr: "127.0.0.1", + adminToken: "", + molEnv: "dev", + want: "127.0.0.1", + }, + { + name: "explicit_bindaddr_wildcard_overrides_devmode_default", + bindAddr: "0.0.0.0", + adminToken: "", + molEnv: "dev", + want: "0.0.0.0", + }, + { + name: "explicit_bindaddr_in_production", + bindAddr: "10.0.5.7", + adminToken: "secret", + molEnv: "production", + want: "10.0.5.7", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("BIND_ADDR", tc.bindAddr) + t.Setenv("ADMIN_TOKEN", tc.adminToken) + t.Setenv("MOLECULE_ENV", tc.molEnv) + got := resolveBindHost() + if got != tc.want { + t.Errorf("resolveBindHost() = %q, want %q (BIND_ADDR=%q ADMIN_TOKEN=%q MOLECULE_ENV=%q)", + got, tc.want, tc.bindAddr, tc.adminToken, tc.molEnv) + } + }) + } +} diff --git a/workspace-server/cmd/server/main.go b/workspace-server/cmd/server/main.go index 45597367..b8e0e979 100644 --- a/workspace-server/cmd/server/main.go +++ b/workspace-server/cmd/server/main.go @@ -19,6 +19,7 @@ import ( "github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers" "github.com/Molecule-AI/molecule-monorepo/platform/internal/imagewatch" memwiring "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/wiring" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware" "github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads" "github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner" "github.com/Molecule-AI/molecule-monorepo/platform/internal/registry" @@ -337,15 +338,23 @@ func main() { // Router r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, wh, channelMgr, memBundle) - // HTTP server with graceful shutdown + // HTTP server with graceful shutdown. + // + // Bind host: in dev-mode (no ADMIN_TOKEN, MOLECULE_ENV=dev|development) + // the AdminAuth chain fails open by design; pairing that with a wildcard + // bind would expose unauth /workspaces to any same-LAN peer. Default to + // loopback when fail-open is active. Operators who need LAN exposure set + // BIND_ADDR=0.0.0.0 explicitly. Production (ADMIN_TOKEN set) is unchanged. + // See molecule-core#7. + bindHost := resolveBindHost() srv := &http.Server{ - Addr: fmt.Sprintf(":%s", port), + Addr: fmt.Sprintf("%s:%s", bindHost, port), Handler: r, } // Start server in goroutine go func() { - log.Printf("Platform starting on :%s", port) + log.Printf("Platform starting on %s:%s (dev-mode-fail-open=%v)", bindHost, port, middleware.IsDevModeFailOpen()) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed: %v", err) } @@ -380,6 +389,29 @@ func envOr(key, fallback string) string { return fallback } +// resolveBindHost picks the listener interface for the HTTP server. +// +// Precedence: +// 1. BIND_ADDR — explicit operator override (any value, including "0.0.0.0"). +// 2. dev-mode fail-open active → "127.0.0.1" (loopback only). +// 3. otherwise → "" (Go binds every interface; existing prod/self-host shape). +// +// Coupling the loopback default to middleware.IsDevModeFailOpen() means the +// two safety levers — bind narrowness and auth strength — move together. A +// production deploy (ADMIN_TOKEN set) keeps binding to all interfaces because +// the auth chain is doing its job; a dev Mac (no ADMIN_TOKEN, MOLECULE_ENV=dev) +// is reachable only via loopback because the auth chain is fail-open. See +// molecule-core#7 for the original LAN exposure finding. +func resolveBindHost() string { + if v := os.Getenv("BIND_ADDR"); v != "" { + return v + } + if middleware.IsDevModeFailOpen() { + return "127.0.0.1" + } + return "" +} + func findConfigsDir() string { candidates := []string{ "workspace-configs-templates",