Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions workspace-server/internal/handlers/restart_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,23 @@ func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTe
// 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 != "" {
runtimeTemplate := filepath.Join(configsDir, dbRuntime+"-default")
safeRuntime := sanitizeRuntime(dbRuntime)
runtimeTemplate := filepath.Join(configsDir, safeRuntime+"-default")
if _, err := os.Stat(runtimeTemplate); err == nil {
label := dbRuntime + "-default"
label := safeRuntime + "-default"
log.Printf("Restart: applying template %s (runtime change)", label)
return runtimeTemplate, label
}
Expand Down
65 changes: 65 additions & 0 deletions workspace-server/internal/handlers/restart_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,68 @@ func TestResolveRestartTemplate_Priority_ExplicitBeatsApplyTemplate(t *testing.T
t.Errorf("expected path %q, got %q", expected, path)
}
}

// TestResolveRestartTemplate_CWE22_TraversalRuntime_FallsThrough is the
// regression test for CWE-22 in Tier 4 of resolveRestartTemplate.
//
// An attacker who holds a workspace token can set the runtime field to a
// path-traversal string (e.g. "../../../etc"). Before the fix, the code
// did:
// runtimeTemplate := filepath.Join(configsDir, dbRuntime+"-default")
// which on a host with /configs/../../../etc-default would return /etc-default,
// injecting arbitrary host files into the workspace container.
//
// After the fix, sanitizeRuntime is called first. Unknown runtimes
// (including traversal strings) are remapped to "langgraph". The attacker
// cannot choose an arbitrary host path — they can at most trigger
// langgraph-default if that template happens to exist.
//
// This test verifies that a traversal string in dbRuntime falls through to
// "existing-volume" when no langgraph-default template is present.
func TestResolveRestartTemplate_CWE22_TraversalRuntime_FallsThrough(t *testing.T) {
root := newTemplateDir(t) // no template dirs at all

for _, tc := range []struct {
name string
dbRuntime string
}{
{"simple traversal", "../../../etc"},
{"mid-path traversal", "langgraph/../../../etc"},
{"absolute-path attempt", "/etc/passwd"},
{"double-dot chain", "../.."},
{"deep traversal", "a/b/c/../../../d"},
} {
t.Run(tc.name, func(t *testing.T) {
path, label := resolveRestartTemplate(root, "Some Workspace", tc.dbRuntime, restartTemplateInput{
ApplyTemplate: true,
})
// Must NOT return a path that escapes root
if path != "" {
t.Errorf("CWE-22: traversal runtime %q must not resolve; got path=%q", tc.dbRuntime, path)
}
if label != "existing-volume" {
t.Errorf("CWE-22: traversal runtime %q must fall through to existing-volume; got label=%q", tc.dbRuntime, label)
}
})
}
}

// TestResolveRestartTemplate_CWE22_TraversalRuntime_CannotOverrideKnownRuntime
// verifies that even if a langgraph-default template exists, a traversal
// string in dbRuntime resolves langgraph-default (the safe default) rather
// than any attacker-chosen path. The attacker gains no additional access.
func TestResolveRestartTemplate_CWE22_TraversalRuntime_CannotOverrideKnownRuntime(t *testing.T) {
root := newTemplateDir(t, "langgraph-default")

path, label := resolveRestartTemplate(root, "Some Workspace", "../../../etc", restartTemplateInput{
ApplyTemplate: true,
})
// Must resolve to langgraph-default, not to an escaped path
expected := filepath.Join(root, "langgraph-default")
if path != expected {
t.Errorf("traversal runtime must resolve to langgraph-default; got path=%q", path)
}
if label != "langgraph-default" {
t.Errorf("label must be langgraph-default; got %q", label)
}
}
Loading