From dccc8f53cb758e283378e945c3531e9464e4f433 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Tue, 12 May 2026 16:29:55 +0000 Subject: [PATCH 1/2] test(handlers): add workspace_crud validation helper tests (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the three pure validator functions introduced in #685/#688: validateWorkspaceID(id): - valid UUID forms (nil error) - empty, traversal, SQL injection, short, invalid hex → error validateWorkspaceDir(dir): - absolute non-system paths → nil - relative paths → error - traversal sequences (..) → error - system paths (/etc, /proc, /sys, /dev, /boot, /sbin, /bin, /lib, /usr, /var) → error - prefixes of system paths → error validateWorkspaceFields(name, role, model, runtime): - all-empty → nil - valid values → nil - name > 255 chars → error; exactly 255 → nil - role > 1000 chars → error - model > 100 chars → error - runtime > 100 chars → error - \n or \r in any field → error - YAML special chars ({ } [ ] | > * & !) in name/role → error - YAML chars allowed in model/runtime (only name/role are gated) Co-Authored-By: Claude Opus 4.7 --- .../workspace_crud_validators_test.go | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 workspace-server/internal/handlers/workspace_crud_validators_test.go diff --git a/workspace-server/internal/handlers/workspace_crud_validators_test.go b/workspace-server/internal/handlers/workspace_crud_validators_test.go new file mode 100644 index 00000000..b1de8741 --- /dev/null +++ b/workspace-server/internal/handlers/workspace_crud_validators_test.go @@ -0,0 +1,263 @@ +package handlers + +import ( + "testing" +) + +// ── validateWorkspaceID ───────────────────────────────────────────────────────── + +func TestValidateWorkspaceID_Valid(t *testing.T) { + cases := []string{ + "550e8400-e29b-41d4-a716-446655440000", + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + } + for _, id := range cases { + t.Run(id, func(t *testing.T) { + if err := validateWorkspaceID(id); err != nil { + t.Errorf("validateWorkspaceID(%q) returned error: %v", id, err) + } + }) + } +} + +func TestValidateWorkspaceID_Invalid(t *testing.T) { + cases := []struct { + name string + id string + }{ + {"empty", ""}, + {"not a UUID", "not-a-uuid"}, + {"traversal attack", "../../etc/passwd"}, + {"SQL injection", "'; DROP TABLE workspaces;--"}, + {"UUID too short", "550e8400-e29b-41d4-a716"}, + {"UUID with invalid hex chars", "550e8400-e29b-41d4-a716-44665544000g"}, + {"UUID all zeros", "00000000000000000000000000000000"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := validateWorkspaceID(tc.id); err == nil { + t.Errorf("validateWorkspaceID(%q): expected error, got nil", tc.id) + } + }) + } +} + +// ── validateWorkspaceDir ─────────────────────────────────────────────────────── + +func TestValidateWorkspaceDir_Valid(t *testing.T) { + cases := []string{ + "/opt/molecule/workspaces/dev", + "/home/user/.molecule/workspaces", + "/var/data/workspace-abc-123", + "/opt/services/molecule/tenant-workspaces", + } + for _, dir := range cases { + t.Run(dir, func(t *testing.T) { + if err := validateWorkspaceDir(dir); err != nil { + t.Errorf("validateWorkspaceDir(%q) returned error: %v", dir, err) + } + }) + } +} + +func TestValidateWorkspaceDir_RelativeRejected(t *testing.T) { + cases := []string{ + "relative/path", + "./myworkspace", + "~/workspaces/dev", + } + for _, dir := range cases { + t.Run(dir, func(t *testing.T) { + if err := validateWorkspaceDir(dir); err == nil { + t.Errorf("validateWorkspaceDir(%q): expected error (relative path), got nil", dir) + } + }) + } +} + +func TestValidateWorkspaceDir_TraversalRejected(t *testing.T) { + cases := []string{ + "/opt/molecule/../../../etc", + "/workspaces/dev/../../root", + "/opt/../opt/../etc", + } + for _, dir := range cases { + t.Run(dir, func(t *testing.T) { + if err := validateWorkspaceDir(dir); err == nil { + t.Errorf("validateWorkspaceDir(%q): expected error (traversal), got nil", dir) + } + }) + } +} + +func TestValidateWorkspaceDir_SystemPathsRejected(t *testing.T) { + cases := []string{ + "/etc", + "/etc/molecule", + "/var", + "/var/log", + "/proc", + "/proc/self", + "/sys", + "/sys/kernel", + "/dev", + "/dev/null", + "/boot", + "/sbin", + "/bin", + "/lib", + "/usr", + "/usr/local", + } + for _, dir := range cases { + t.Run(dir, func(t *testing.T) { + if err := validateWorkspaceDir(dir); err == nil { + t.Errorf("validateWorkspaceDir(%q): expected error (system path), got nil", dir) + } + }) + } +} + +func TestValidateWorkspaceDir_PrefixMatchesBlocked(t *testing.T) { + // The blocklist checks prefix so /etc/foo must also be rejected. + cases := []string{ + "/etc/molecule-config", + "/var/log/workspace", + "/usr/local/bin", + "/usr/bin/molecule", + } + for _, dir := range cases { + t.Run(dir, func(t *testing.T) { + if err := validateWorkspaceDir(dir); err == nil { + t.Errorf("validateWorkspaceDir(%q): expected error (prefix of blocked path), got nil", dir) + } + }) + } +} + +// ── validateWorkspaceFields ──────────────────────────────────────────────────── + +func TestValidateWorkspaceFields_AllEmpty(t *testing.T) { + // All empty → valid (creation uses defaults; empty is allowed) + if err := validateWorkspaceFields("", "", "", ""); err != nil { + t.Errorf("validateWorkspaceFields with all empty: expected nil, got %v", err) + } +} + +func TestValidateWorkspaceFields_Valid(t *testing.T) { + if err := validateWorkspaceFields("My Workspace", "Backend Engineer", "gpt-4o", "langgraph"); err != nil { + t.Errorf("validateWorkspaceFields with valid args: expected nil, got %v", err) + } +} + +func TestValidateWorkspaceFields_NameTooLong(t *testing.T) { + longName := make([]byte, 256) + for i := range longName { + longName[i] = 'a' + } + if err := validateWorkspaceFields(string(longName), "", "", ""); err == nil { + t.Error("name > 255 chars: expected error, got nil") + } + + // Exactly 255 chars is OK + validName := make([]byte, 255) + for i := range validName { + validName[i] = 'a' + } + if err := validateWorkspaceFields(string(validName), "", "", ""); err != nil { + t.Errorf("name exactly 255 chars: expected nil, got %v", err) + } +} + +func TestValidateWorkspaceFields_RoleTooLong(t *testing.T) { + longRole := make([]byte, 1001) + for i := range longRole { + longRole[i] = 'x' + } + if err := validateWorkspaceFields("", string(longRole), "", ""); err == nil { + t.Error("role > 1000 chars: expected error, got nil") + } +} + +func TestValidateWorkspaceFields_ModelTooLong(t *testing.T) { + longModel := make([]byte, 101) + for i := range longModel { + longModel[i] = 'x' + } + if err := validateWorkspaceFields("", "", string(longModel), ""); err == nil { + t.Error("model > 100 chars: expected error, got nil") + } +} + +func TestValidateWorkspaceFields_RuntimeTooLong(t *testing.T) { + longRuntime := make([]byte, 101) + for i := range longRuntime { + longRuntime[i] = 'x' + } + if err := validateWorkspaceFields("", "", "", string(longRuntime)); err == nil { + t.Error("runtime > 100 chars: expected error, got nil") + } +} + +func TestValidateWorkspaceFields_NewlineInName(t *testing.T) { + if err := validateWorkspaceFields("My\nWorkspace", "", "", ""); err == nil { + t.Error("name with \\n: expected error, got nil") + } +} + +func TestValidateWorkspaceFields_CRLFInRole(t *testing.T) { + if err := validateWorkspaceFields("", "Backend\r\nEngineer", "", ""); err == nil { + t.Error("role with \\r\\n: expected error, got nil") + } +} + +func TestValidateWorkspaceFields_NewlineInModel(t *testing.T) { + if err := validateWorkspaceFields("", "", "gpt-\n4o", ""); err == nil { + t.Error("model with \\n: expected error, got nil") + } +} + +func TestValidateWorkspaceFields_NewlineInRuntime(t *testing.T) { + if err := validateWorkspaceFields("", "", "", "lang\rgraph"); err == nil { + t.Error("runtime with \\r: expected error, got nil") + } +} + +func TestValidateWorkspaceFields_YAMLSpecialChars(t *testing.T) { + // yamlSpecialChars = "{}[]|>*&!" + // These must be rejected in name and role. + dangerous := []string{ + "Workspace{evil}", + "Workspace[evil]", + "Workspace]evil[", + "Workspace|evil", + "Workspace>evil", + "Workspace*evil", + "Workspace&evil", + "Workspace!evil", + "Name{}", + "Role[]", + } + for _, v := range dangerous { + t.Run(v, func(t *testing.T) { + if err := validateWorkspaceFields(v, "", "", ""); err == nil { + t.Errorf("name %q: expected error (YAML special char), got nil", v) + } + }) + } +} + +func TestValidateWorkspaceFields_YAMLCharsAllowedInModelRuntime(t *testing.T) { + // YAML special chars are only blocked in name/role, not model/runtime. + if err := validateWorkspaceFields("", "", "model{}[]", "runtime*&!"); err != nil { + t.Errorf("model/runtime with YAML chars: expected nil, got %v", err) + } +} + +func TestValidateWorkspaceFields_YAMLCharsAllowedInEmptyName(t *testing.T) { + // Empty name is fine; YAML char restriction is only on non-empty values. + if err := validateWorkspaceFields("", "Backend Engineer", "", ""); err != nil { + t.Errorf("empty name with valid role: expected nil, got %v", err) + } +} From 5e5fb503ec2eb469da4e25fac4731b17be09a3bf Mon Sep 17 00:00:00 2001 From: core-devops Date: Tue, 12 May 2026 20:51:20 +0000 Subject: [PATCH 2/2] ci: rerun after mc#724 all-required fix lands