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>
110 lines
4.8 KiB
Go
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"
|
|
}
|