diff --git a/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx b/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx index 9730bd13..330006cd 100644 --- a/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx +++ b/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx @@ -226,6 +226,7 @@ describe("ContextMenu — keyboard accessibility", () => { id: "ws-1", name: "Alpha Workspace", hasChildren: false, + children: [], }); expect(closeContextMenu).toHaveBeenCalled(); }); diff --git a/workspace-server/internal/handlers/container_files.go b/workspace-server/internal/handlers/container_files.go index 28c57e11..349ab53b 100644 --- a/workspace-server/internal/handlers/container_files.go +++ b/workspace-server/internal/handlers/container_files.go @@ -83,7 +83,15 @@ func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerNa return fmt.Errorf("unsafe file path in archive: %s", name) } // Prepend destPath so relative paths land inside the volume mount. - archiveName := filepath.Join(destPath, name) + // Use cleaned name so validation (which checks clean) and usage stay consistent. + archiveName := filepath.Join(destPath, clean) + // Defence-in-depth: ensure the joined path doesn't escape destPath. + // This guards against platform-specific filepath.Join behaviour where + // joining a relative name containing ".." with a destPath can still + // produce an absolute path outside the intended directory. + if !strings.HasPrefix(archiveName, destPath) && archiveName != destPath { + return fmt.Errorf("path escapes destination: %s", name) + } // Create parent directories in tar (deduplicated) dir := filepath.Dir(archiveName) diff --git a/workspace-server/internal/handlers/ssrf.go b/workspace-server/internal/handlers/ssrf.go new file mode 100644 index 00000000..09bb2774 --- /dev/null +++ b/workspace-server/internal/handlers/ssrf.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "fmt" + "net" + "net/url" + "path/filepath" + "strings" +) + +// isSafeURL validates that a URL resolves to a publicly-routable address, +// preventing A2A requests from being redirected to internal/cloud-metadata +// infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches +// so we validate before making any outbound HTTP call. +func isSafeURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + // Reject non-HTTP(S) schemes. + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("forbidden scheme: %s (only http/https allowed)", u.Scheme) + } + host := u.Hostname() + if host == "" { + return fmt.Errorf("empty hostname") + } + // Block direct IP addresses. + if ip := net.ParseIP(host); ip != nil { + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() { + return fmt.Errorf("forbidden loopback/unspecified IP: %s", ip) + } + if isPrivateOrMetadataIP(ip) { + return fmt.Errorf("forbidden private/metadata IP: %s", ip) + } + return nil + } + // For hostnames, resolve and validate each returned IP. + addrs, err := net.LookupHost(host) + if err != nil { + // DNS resolution failure — block it. Could be an internal hostname. + return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err) + } + if len(addrs) == 0 { + return fmt.Errorf("DNS returned no addresses for: %s", host) + } + for _, addr := range addrs { + ip := net.ParseIP(addr) + if ip != nil && (ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || isPrivateOrMetadataIP(ip)) { + return fmt.Errorf("hostname %s resolves to forbidden IP: %s", host, ip) + } + } + return nil +} + +// isPrivateOrMetadataIP returns true for RFC-1918 private, carrier-grade NAT, +// link-local, and cloud metadata ranges. +func isPrivateOrMetadataIP(ip net.IP) bool { + var privateRanges = []net.IPNet{ + {IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(8, 32)}, + {IP: net.ParseIP("172.16.0.0"), Mask: net.CIDRMask(12, 32)}, + {IP: net.ParseIP("192.168.0.0"), Mask: net.CIDRMask(16, 32)}, + {IP: net.ParseIP("169.254.0.0"), Mask: net.CIDRMask(16, 32)}, + {IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)}, + {IP: net.ParseIP("192.0.2.0"), Mask: net.CIDRMask(24, 32)}, + {IP: net.ParseIP("198.51.100.0"), Mask: net.CIDRMask(24, 32)}, + {IP: net.ParseIP("203.0.113.0"), Mask: net.CIDRMask(24, 32)}, + } + ip = ip.To4() + if ip == nil { + return false + } + for _, r := range privateRanges { + if r.Contains(ip) { + return true + } + } + return false +} + +// validateRelPath checks that a file path is relative and does not escape +// the destination via absolute paths or ".." traversal. Used by +// copyFilesToContainer and deleteViaEphemeral as a defence-in-depth measure. +func validateRelPath(filePath string) error { + clean := filepath.Clean(filePath) + if filepath.IsAbs(clean) || strings.Contains(clean, "..") { + return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath) + } + return nil +} \ No newline at end of file