Merge branch 'main' into sweep/internal-219-cat-B-delete-github-only
All checks were successful
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Successful in 9s

This commit is contained in:
claude-ceo-assistant 2026-05-11 05:40:27 +00:00
commit 298c237a5a
3 changed files with 122 additions and 2 deletions

View File

@ -91,6 +91,10 @@ func expandWithEnv(s string, env map[string]string) string {
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
// (workspace overrides org root). Used by both secret injection and channel
// config expansion.
//
// SECURITY: filesDir is sourced from untrusted org YAML input (ws.FilesDir).
// resolveInsideRoot guard prevents path traversal (CWE-22) where a malicious
// filesDir like "../../../etc" could escape the org root.
func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
envVars := map[string]string{}
if orgBaseDir == "" {
@ -98,7 +102,14 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
}
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
if filesDir != "" {
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
safeFilesDir, err := resolveInsideRoot(orgBaseDir, filesDir)
if err != nil {
// Reject traversal attempt silently — callers expect an empty map
// on any read failure.
log.Printf("loadWorkspaceEnv: rejecting filesDir %q: %v", filesDir, err)
return envVars
}
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
}
return envVars
}

View File

@ -0,0 +1,104 @@
package handlers
import (
"os"
"path/filepath"
"testing"
)
// TestLoadWorkspaceEnv_RejectsTraversal asserts that loadWorkspaceEnv refuses
// to read workspace-specific .env files when filesDir contains CWE-22 traversal
// patterns (../../../etc, absolute paths, etc.). This is the primary security
// control for the ws.FilesDir attack surface in POST /org/import.
func TestLoadWorkspaceEnv_RejectsTraversal(t *testing.T) {
tmp := t.TempDir()
orgRoot := filepath.Join(tmp, "my-org")
if err := os.Mkdir(orgRoot, 0o755); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
filesDir string
}{
{"traversal_parent", "../../../etc"},
{"traversal_deep", "../../../../../../../../../etc"},
{"traversal_sibling", "../sibling"},
{"traversal_mixed", "foo/../../bar"},
{"absolute_path", "/etc/passwd"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Write an org-level .env to confirm it loads even when the
// workspace .env is rejected.
orgEnv := filepath.Join(orgRoot, ".env")
if err := os.WriteFile(orgEnv, []byte("ORG_KEY=org-value\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(orgRoot, tc.filesDir)
// Org-level .env must be loaded regardless of workspace rejection.
if got["ORG_KEY"] != "org-value" {
t.Errorf("org-level .env not loaded: got %v", got)
}
// Traversal path must NOT have been read.
if val, ok := got["TRAVERSAL_KEY"]; ok {
t.Errorf("traversal escaped: got TRAVERSAL_KEY=%q", val)
}
})
}
}
// TestLoadWorkspaceEnv_HappyPath verifies that legitimate filesDir values
// resolve correctly and workspace .env overrides org-level values.
func TestLoadWorkspaceEnv_HappyPath(t *testing.T) {
tmp := t.TempDir()
orgRoot := filepath.Join(tmp, "my-org")
wsDir := filepath.Join(orgRoot, "workspaces", "dev-workspace")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
orgEnv := filepath.Join(orgRoot, ".env")
wsEnv := filepath.Join(wsDir, ".env")
if err := os.WriteFile(orgEnv, []byte("ORG_KEY=org-val\nSHARED=org-wins\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(wsEnv, []byte("WS_KEY=ws-val\nSHARED=ws-wins\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(orgRoot, filepath.Join("workspaces", "dev-workspace"))
if got["ORG_KEY"] != "org-val" {
t.Errorf("org-level key missing: %v", got)
}
if got["WS_KEY"] != "ws-val" {
t.Errorf("workspace key missing: %v", got)
}
if got["SHARED"] != "ws-wins" {
t.Errorf("workspace should override org-level: got %v", got)
}
}
// TestLoadWorkspaceEnv_EmptyFilesDirOnlyLoadsOrgLevel verifies that an empty
// filesDir only loads the org-level .env (no workspace override).
func TestLoadWorkspaceEnv_EmptyFilesDir(t *testing.T) {
tmp := t.TempDir()
orgRoot := filepath.Join(tmp, "my-org")
if err := os.Mkdir(orgRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(orgRoot, ".env"), []byte("KEY=only-org\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(orgRoot, "")
if got["KEY"] != "only-org" {
t.Errorf("expected only-org, got %v", got)
}
}

View File

@ -490,8 +490,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// 1. Org root .env (shared defaults)
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
// 2. Workspace-specific .env (overrides)
// SECURITY: ws.FilesDir is untrusted YAML input — guard against CWE-22
// traversal so a crafted filesDir like "../../../etc" cannot escape orgBaseDir.
if ws.FilesDir != "" {
parseEnvFile(filepath.Join(orgBaseDir, ws.FilesDir, ".env"), envVars)
if safeFilesDir, err := resolveInsideRoot(orgBaseDir, ws.FilesDir); err == nil {
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
}
// Traversal rejection: silently skip — callers expect partial env on failure.
}
}
// Store as workspace secrets via DB (encrypted if key is set, raw otherwise)