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/handlers"
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/imagewatch"
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/imagewatch"
|
||||||
memwiring "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/wiring"
|
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/pendinguploads"
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
|
||||||
@ -337,15 +338,23 @@ func main() {
|
|||||||
// Router
|
// Router
|
||||||
r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, wh, channelMgr, memBundle)
|
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{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%s", port),
|
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||||
Handler: r,
|
Handler: r,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start server in goroutine
|
// Start server in goroutine
|
||||||
go func() {
|
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 {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("Server failed: %v", err)
|
log.Fatalf("Server failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -380,6 +389,29 @@ func envOr(key, fallback string) string {
|
|||||||
return fallback
|
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 {
|
func findConfigsDir() string {
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
"workspace-configs-templates",
|
"workspace-configs-templates",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user