molecule-core/workspace-server/internal/handlers/restart_template.go
Molecule AI Core-OffSec d7901bb831 fix(handlers): apply sanitizeRuntime allowlist before Tier 4 filepath.Join (CWE-22)
CWE-22 path traversal in restartTemplateInput Tier 4: dbRuntime was joined
directly into the template path without sanitisation.

  runtimeTemplate := filepath.Join(configsDir, dbRuntime+"-default")

An attacker holding a workspace token could set runtime to a path-traversal
string (e.g. "../../../etc") via the PATCH /workspaces/:id Update handler,
which only validates length and newlines.  If a matching directory existed
on the host (e.g. /configs/../../../etc-default), the restart would load
files from an arbitrary host path into the workspace container.

Fix: call sanitizeRuntime(dbRuntime) — the existing allowlist in
workspace_provision.go — before filepath.Join.  Unknown values are
remapped to "langgraph", so the attacker cannot choose an arbitrary host
path.  Defense-in-depth: the path is still inside configsDir after
sanitisation.

Regression tests added:
- CWE-22 traversal strings fall through to existing-volume
- langgraph-default is used when traversal string is sanitised to langgraph

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:37:19 +00:00

110 lines
4.8 KiB
Go

package handlers
import (
"log"
"os"
"path/filepath"
)
// restartTemplateInput is the subset of the /workspaces/:id/restart request
// body that affects which config source the provisioner uses. Extracted as
// a type so `resolveRestartTemplate` has a single pure-function signature
// for unit tests — no gin context, no DB, no filesystem writes.
type restartTemplateInput struct {
// Template is an explicit template dir name from the request body.
// Always honoured when resolvable — caller asked by name, that's
// unambiguous consent to overwrite the config volume.
Template string
// ApplyTemplate opts the caller in to name-based auto-match AND the
// runtime-default fallback. Without this flag a restart MUST NOT
// overwrite the user's config volume — a user who edited their
// model/provider/skills/prompts via the Canvas Config tab and hit
// Save+Restart expects their edits to survive. The previous behaviour
// (name-based auto-match unconditionally) silently reverted edits for
// any workspace whose name matched a template dir (e.g. "Hermes Agent"
// → hermes/), which is the regression this fix closes.
ApplyTemplate bool
// RebuildConfig (#239) is the recovery signal used when the workspace's
// config volume was destroyed out-of-band. Tries org-templates as a
// last-resort source so the workspace can self-heal without admin
// intervention. Orthogonal to ApplyTemplate.
RebuildConfig bool
}
// resolveRestartTemplate chooses the config source for a restart in the
// documented priority order:
//
// 1. Explicit `Template` from the request body (always honoured).
// 2. `ApplyTemplate=true` → name-based auto-match via findTemplateByName.
// 3. `RebuildConfig=true` → org-templates recovery fallback (#239).
// 4. `ApplyTemplate=true` + non-empty dbRuntime → runtime-default template
// (e.g. `hermes-default/`) for runtime-change workflows.
// 5. Fall through → empty path + "existing-volume" label. Provisioner
// reuses the workspace's existing config volume from the previous run.
//
// Returns (templatePath, configLabel). An empty templatePath is the signal
// to the provisioner that the existing volume is authoritative — the flow
// that preserves user edits.
//
// Pure function: no writes, no DB access, no network. Safe to unit-test
// with just a temp directory.
func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTemplateInput) (templatePath, configLabel string) {
template := body.Template
// Tier 2: name-based auto-match, gated on ApplyTemplate.
if template == "" && body.ApplyTemplate {
template = findTemplateByName(configsDir, wsName)
}
// Tier 1 + 2 resolve via the same code path — validate + stat.
if template != "" {
candidatePath, resolveErr := resolveInsideRoot(configsDir, template)
if resolveErr != nil {
log.Printf("Restart: invalid template %q: %v — proceeding without it", template, resolveErr)
template = ""
} else if _, err := os.Stat(candidatePath); err == nil {
return candidatePath, template
} else {
log.Printf("Restart: template %q dir not found — proceeding without it", template)
}
}
// Tier 3: #239 rebuild_config — org-templates as last-resort recovery.
if body.RebuildConfig {
if p, label := resolveOrgTemplate(configsDir, wsName); p != "" {
log.Printf("Restart: rebuild_config — using org-template %s (%s)", label, wsName)
return p, label
}
}
// Tier 4: runtime-default — apply_template=true + known runtime.
// Use case: Canvas Config tab changed the runtime; we need the new
// runtime's base files (entry point, Dockerfile, skill scaffolding)
// because the existing volume was written by the old runtime.
//
// SECURITY (CWE-22 / F1502): dbRuntime comes from the workspaces DB
// column — set by the PATCH Update handler which only validates length
// and newlines, not path-traversal characters. Without sanitisation an
// attacker who holds a workspace token could set runtime to
// "../../../etc" and, if a directory matching that path existed on the
// host, load an arbitrary host directory as the workspace template.
//
// sanitizeRuntime applies an allowlist of known runtimes; any unknown
// value (including traversal strings) is remapped to "langgraph". The
// attacker cannot choose an arbitrary host path — they can at most
// trigger application of the langgraph-default template.
if body.ApplyTemplate && dbRuntime != "" {
safeRuntime := sanitizeRuntime(dbRuntime)
runtimeTemplate := filepath.Join(configsDir, safeRuntime+"-default")
if _, err := os.Stat(runtimeTemplate); err == nil {
label := safeRuntime + "-default"
log.Printf("Restart: applying template %s (runtime change)", label)
return runtimeTemplate, label
}
}
// Tier 5: reuse existing volume. This is the default, and the path
// the Canvas Save+Restart flow MUST hit to preserve user edits.
return "", "existing-volume"
}