fix(security): #164 + #165 + #166 — gate 6 unauth routes behind AdminAuth

CRITICAL (#164):
  POST /bundles/import — anon callers could create arbitrary workspaces
  with user-supplied system prompts, plugins, and secrets envelopes.
  Fixed by gating behind AdminAuth (bundleAdmin group).

HIGH (#165):
  GET /bundles/export/:id — anon UUID probe leaked full system prompts,
  agent_card, plugins, memory for any workspace.
  GET /events + GET /events/:workspaceId — anon read of the append-only
  event log leaked org topology, workspace names, card fragments.
  Both moved into the same bundleAdmin / eventsAdmin groups.

MEDIUM (#166):
  PUT /canvas/viewport — anon callers could reset shared viewport state.
  Gated via a scoped viewportAdmin group; GET stays open so canvas
  bootstraps without a bearer.
  GET /admin/liveness — operational-intel leak (scheduler cadence
  reveals work pattern). Inline AdminAuth on the single handler.

All 6 routes use the same lazy-bootstrap admin auth the rest of the
platform uses: zero-token installs fail-open, once any token exists
every request must present a valid bearer.

Known follow-up: canvas uses session cookies not bearer tokens (same
pattern as #138). In multi-tenant production these canvas features —
Events tab, Export/Duplicate, viewport persist — will return 401 once
a workspace is token-enrolled. Needs cookie-accepting AdminAuth as a
follow-up (tracked as option B in #138 triage discussion); a new issue
will be filed for that scope. The security gain from closing #164
CRITICAL outweighs the canvas UX regression for tonight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-15 09:52:32 -07:00
parent 175bc2de50
commit 186206b33f

View File

@ -76,7 +76,10 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// to catch stuck-but-not-crashed goroutines (the failure mode that caused
// the 12h scheduler outage of 2026-04-14, issue #85). Any subsystem whose
// last tick is older than 2× its expected interval is stale.
r.GET("/admin/liveness", func(c *gin.Context) {
//
// #166: gated behind AdminAuth. Internal health state is an ops-intel leak
// in production (scheduler tick cadence reveals fleet size + work pattern).
r.GET("/admin/liveness", middleware.AdminAuth(db.DB), func(c *gin.Context) {
snap := supervised.Snapshot()
out := make(map[string]interface{}, len(snap))
now := time.Now()
@ -196,10 +199,16 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
r.GET("/registry/:id/peers", dh.Peers)
r.POST("/registry/check-access", dh.CheckAccess)
// Events (not workspace-scoped — exempt from per-workspace auth)
// Events — #165: gated behind AdminAuth. The raw event log contains org
// topology, workspace names, and agent-card fragments; an unauth read
// leaks the entire fleet structure. GET /events/:workspaceId is still
// a cross-workspace read so it uses AdminAuth, not WorkspaceAuth.
eh := handlers.NewEventsHandler()
r.GET("/events", eh.List)
r.GET("/events/:workspaceId", eh.ListByWorkspace)
{
eventsAdmin := r.Group("", middleware.AdminAuth(db.DB))
eventsAdmin.GET("/events", eh.List)
eventsAdmin.GET("/events/:workspaceId", eh.ListByWorkspace)
}
// Remaining auth-gated workspace sub-routes — appended to wsAuth group declared above.
{
@ -274,10 +283,16 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
th := handlers.NewTerminalHandler(dockerCli)
wsAuth.GET("/terminal", th.HandleConnect)
// Canvas Viewport
// Canvas Viewport — #166: PUT gated behind AdminAuth so an anon caller
// can't reset the shared viewport state for all users. GET remains open
// because the canvas bootstraps without a bearer and needs the initial
// viewport for first paint.
vh := handlers.NewViewportHandler()
r.GET("/canvas/viewport", vh.Get)
r.PUT("/canvas/viewport", vh.Save)
{
viewportAdmin := r.Group("", middleware.AdminAuth(db.DB))
viewportAdmin.PUT("/canvas/viewport", vh.Save)
}
// Templates
tmplh := handlers.NewTemplatesHandler(configsDir, dockerCli)
@ -317,10 +332,18 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// unpack locally instead of going through Docker exec.
wsAuth.GET("/plugins/:name/download", plgh.Download)
// Bundles
// Bundles — #164 + #165: both gated behind AdminAuth.
// POST /bundles/import — CRITICAL: anon creation of arbitrary workspaces
// with user-supplied config (system prompts,
// plugins, secrets envelope). #164.
// GET /bundles/export/:id — HIGH: full system prompts + memory for any
// workspace by UUID probe. #165.
bh := handlers.NewBundleHandler(broadcaster, prov, platformURL, configsDir, dockerCli)
r.GET("/bundles/export/:id", bh.Export)
r.POST("/bundles/import", bh.Import)
{
bundleAdmin := r.Group("", middleware.AdminAuth(db.DB))
bundleAdmin.GET("/bundles/export/:id", bh.Export)
bundleAdmin.POST("/bundles/import", bh.Import)
}
// Org Templates
orgDir := findOrgDir(configsDir)