Files
molecule-core/workspace-server/internal/middleware/devmode.go
core-devops 3747fe2f49 harden(security): remove dev-mode fail-open auth — fail-closed everywhere + dev-token + regression gate
CTO directive: "nothing should be fail-open." Remove the dev-mode fail-open
auth hatch so AdminAuth/WorkspaceAuth (and the discovery caller) ALWAYS
require a real credential — fail-CLOSED in every environment, dev included —
fix local dev to stay AUTHENTICATED (not open), and add a regression gate so
fail-open cannot return.

Removed fail-open call-sites (workspace-server):
- internal/middleware/wsauth_middleware.go WorkspaceAuth — deleted the
  isDevModeFailOpen() short-circuit that let a bearer-less /workspaces/:id/*
  request through when MOLECULE_ENV=dev + ADMIN_TOKEN unset.
- internal/middleware/wsauth_middleware.go AdminAuth — deleted BOTH fail-open
  branches: the Tier-1 lazy-bootstrap (no live tokens + no ADMIN_TOKEN ⇒ pass,
  the C4 /org/import pre-empt hole) and the Tier-1b isDevModeFailOpen() dev
  hatch. HasAnyLiveTokenGlobal is still probed for the 503-on-outage semantics
  but opens no path.
- internal/handlers/discovery.go validateDiscoveryCaller — deleted the
  IsDevModeFailOpen() allow branch; discovery now requires a verified CP
  session or valid bearer in every env.
- Removed the isDevModeFailOpen()/IsDevModeFailOpen() helper entirely. The two
  legitimately non-auth uses (rate-limit relaxation in ratelimit.go, loopback
  bind default in cmd/server) now key on a new NON-security isLocalDevEnv()
  predicate (MOLECULE_ENV only, decoupled from ADMIN_TOKEN). CanvasOrBearer's
  cosmetic-only behaviour (PUT /canvas/viewport) is unchanged.

Dev path stays authenticated, not open:
- scripts/dev-start.sh provisions a deterministic ADMIN_TOKEN into .env and
  exports the matching NEXT_PUBLIC_ADMIN_TOKEN so the dev Canvas sends a real
  bearer (canvas/src/lib/api.ts already attaches it; next.config.ts pair-guard).
- Docs updated: .env.example, docs/quickstart.md, docs/architecture/overview.md.

Regression gate:
- internal/middleware/no_fail_open_test.go — asserts AdminAuth + WorkspaceAuth
  fail CLOSED (401) under the EXACT old-hatch conditions (ADMIN_TOKEN unset +
  MOLECULE_ENV=dev/development × hasLive 0/1). Proven RED against a temporarily
  restored hatch, GREEN after. Plus a source-guard test forbidding the
  isDevModeFailOpen(-style helper from re-appearing.
- Converted the stale fail-open assertions in wsauth_middleware_test.go,
  discovery_test.go, security_regression_685_686_687_688_test.go and the
  devmode/bind tests to pin the fail-closed contract.

Audit (other fail-open patterns on the auth surface): CanvasOrBearer and
validateDiscoveryCaller retain a fail-open-on-DB-error (and CanvasOrBearer a
no-token lazy-bootstrap) — both are documented availability tradeoffs on
cosmetic / low-sensitivity routes, left as-is and flagged for follow-up.

Verify: go build ./... ok; go vet middleware/cmd/handlers clean; full module
go test ./... = 46 ok / 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:02:48 -07:00

58 lines
2.5 KiB
Go

package middleware
import (
"os"
"strings"
)
// Local-dev environment detection.
//
// SECURITY (harden/no-fail-open-auth): this file used to export an auth
// escape hatch — `isDevModeFailOpen()` — that let AdminAuth, WorkspaceAuth,
// and the discovery handler serve admin/workspace-protected endpoints with
// NO bearer token whenever `ADMIN_TOKEN` was unset and `MOLECULE_ENV` was a
// dev value. The CTO directive is "nothing should be fail-open": auth is now
// fail-CLOSED in every environment, dev included. The hatch is GONE.
//
// What remains here is a NON-security predicate, `isLocalDevEnv()`, that
// reports ONLY whether `MOLECULE_ENV` names a local-dev environment. It does
// NOT consult `ADMIN_TOKEN` and it does NOT influence authentication. It is
// used for two convenience/defense-in-depth knobs that never grant access:
//
// - ratelimit.go: relax the per-caller request bucket on a single-user
// local stack (a DoS knob, not a credential — relaxing it cannot expose
// any protected data).
// - cmd/server resolveBindHost(): default the HTTP listener to loopback
// (127.0.0.1) in local dev. This is strictly *safer* than binding all
// interfaces and is unrelated to whether a request is authenticated.
//
// Local dev now stays AUTHENTICATED, not open: scripts/dev-start.sh
// provisions a deterministic `ADMIN_TOKEN` and hands the matching
// `NEXT_PUBLIC_ADMIN_TOKEN` to the Canvas, so the browser sends a real
// bearer. See scripts/dev-start.sh and canvas/src/lib/api.ts.
// devModeEnvValues is the set of MOLECULE_ENV values that count as
// "explicit local dev". Production callers don't set any of these.
// Case-insensitive compare via strings.ToLower below.
var devModeEnvValues = map[string]struct{}{
"development": {},
"dev": {},
}
// isLocalDevEnv reports whether MOLECULE_ENV names a local-dev environment
// ("development" / "dev"). It carries NO authentication semantics — callers
// must never use it to bypass a credential check. It exists only for
// dev-convenience / defense-in-depth knobs (rate-limit relaxation, loopback
// bind default) that cannot expose protected data.
func isLocalDevEnv() bool {
env := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_ENV")))
_, ok := devModeEnvValues[env]
return ok
}
// IsLocalDevEnv exposes isLocalDevEnv to packages outside the middleware
// module (cmd/server bind-host default). NON-security: see isLocalDevEnv.
func IsLocalDevEnv() bool {
return isLocalDevEnv()
}