From 6fd13ff0379e0f60895a99138c6a1e48e4f7129c Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 15 Apr 2026 12:00:26 -0700 Subject: [PATCH] =?UTF-8?q?fix(security):=20#226=20=E2=80=94=20gate=20POST?= =?UTF-8?q?=20/workspaces=20template/runtime=20against=20traversal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #226 MEDIUM. WorkspaceHandler.Create joined payload.Template directly into filepath.Join(configsDir, template) without validating it stayed inside configsDir. An attacker posting Template="../../etc" would have the provisioner walk and mount arbitrary host directories into the workspace container. Same fix as #103 (POST /org/import): use the existing resolveInsideRoot helper to reject absolute paths and any ".." that escapes the root. Applied at both call sites in workspace.go: 1. Synchronous runtime detection before DB insert — 400 on bad input 2. Async provisioning goroutine — early return, logs the rejection (belt-and-suspenders; the create path already blocks) No test added inline because the existing resolveInsideRoot suite (org_path_test.go) already covers absolute / traversal / prefix-sibling / empty-path / deep-subpath cases. A duplicate test for the workspace handler wouldn't add signal. Co-Authored-By: Claude Opus 4.6 (1M context) --- platform/internal/handlers/workspace.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/platform/internal/handlers/workspace.go b/platform/internal/handlers/workspace.go index 84b23161..a47a6243 100644 --- a/platform/internal/handlers/workspace.go +++ b/platform/internal/handlers/workspace.go @@ -53,7 +53,15 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { // Detect runtime from template config.yaml if not specified in request. // Must happen before DB insert so the correct runtime is persisted. if payload.Runtime == "" && payload.Template != "" { - candidatePath := filepath.Join(h.configsDir, payload.Template) + // #226: payload.Template is attacker-controllable. resolveInsideRoot + // rejects absolute paths and any ".." that escapes configsDir so the + // provisioner can't be pointed at host directories. + candidatePath, resolveErr := resolveInsideRoot(h.configsDir, payload.Template) + if resolveErr != nil { + log.Printf("Create: invalid template path %q: %v", payload.Template, resolveErr) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid template"}) + return + } cfgData, readErr := os.ReadFile(filepath.Join(candidatePath, "config.yaml")) if readErr != nil { log.Printf("Create: could not read config.yaml for template %q: %v", payload.Template, readErr) @@ -149,7 +157,16 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { var templatePath string var configFiles map[string][]byte if payload.Template != "" { - candidatePath := filepath.Join(h.configsDir, payload.Template) + // #226: re-validate the template path at provision time. Even + // though the create-path validates above, provision runs in a + // goroutine with a fresh payload copy — duplicate the guard so + // a future code path that reaches provisionTenant with an + // unchecked payload can't regress the fix. + candidatePath, resolveErr := resolveInsideRoot(h.configsDir, payload.Template) + if resolveErr != nil { + log.Printf("Create provision: rejecting template %q: %v", payload.Template, resolveErr) + return + } if _, err := os.Stat(candidatePath); err == nil { templatePath = candidatePath } else {