fix(workspace-server): default-bind to 127.0.0.1 in dev-mode fail-open
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Retarget main PRs to staging / Retarget to staging (pull_request) Has been skipped
Harness Replays / detect-changes (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Failing after 35s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Failing after 56s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Failing after 1m24s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Failing after 1m25s
CI / Platform (Go) (pull_request) Successful in 1m48s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 4m47s
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Retarget main PRs to staging / Retarget to staging (pull_request) Has been skipped
Harness Replays / detect-changes (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Failing after 35s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Failing after 56s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Failing after 1m24s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Failing after 1m25s
CI / Platform (Go) (pull_request) Successful in 1m48s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 4m47s
In dev mode (`MOLECULE_ENV=dev|development`, `ADMIN_TOKEN` unset) the AdminAuth chain fails open by design so canvas at :3000 can call workspace-server at :8080 without a bearer token. Combined with the existing wildcard bind on `:8080`, that exposed unauthenticated `POST /workspaces` to any same-LAN peer (S-8 in the audit RFC v1). Couple the bind narrowness to the same signal that drives the auth fail-open: when `middleware.IsDevModeFailOpen()` returns true, default the listener to `127.0.0.1`. Production (`ADMIN_TOKEN` set) keeps binding to all interfaces — its auth chain is doing the work. Operators who need LAN exposure set `BIND_ADDR=<host>` explicitly. * `cmd/server/main.go` — `resolveBindHost()` precedence: BIND_ADDR explicit > IsDevModeFailOpen() loopback > "" (all interfaces). Startup log line now includes the resolved bind + dev-mode-fail-open state for post-deploy auditing. * `cmd/server/bind_test.go` — 8 t.Setenv table cases covering precedence, explicit overrides, dev/prod env words. Mutation-tested: removing the `IsDevModeFailOpen()` branch makes the dev-mode cases fail with "" vs "127.0.0.1". Refs: molecule-core#7 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f92ba492de
commit
f3187ea0c1
89
workspace-server/cmd/server/bind_test.go
Normal file
89
workspace-server/cmd/server/bind_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user