Skip to content
Closed
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
41 changes: 20 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ on:
branches: [main, staging]

# Cancel in-progress CI runs when a new commit arrives on the same ref.
# This prevents multiple stale runs from queuing behind each other and
# monopolising the self-hosted macOS arm64 runner.
# This prevents multiple stale runs from queuing and keeps the self-hosted
# macOS arm64 runner (publish-canvas-image, publish-workspace-server-image)
# available for the jobs that genuinely require it.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Expand Down Expand Up @@ -57,7 +58,7 @@ jobs:
name: Platform (Go)
needs: changes
if: needs.changes.outputs.platform == 'true'
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
defaults:
run:
working-directory: workspace-server
Expand All @@ -70,6 +71,10 @@ jobs:
- run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: github.com/Molecule-AI/molecule-cli
- run: go vet ./...
# golangci-lint-action uses a Linux Docker image (ubuntu is the only arch+OS
# combo the official image publishes for). Previously this step was pinned to
# [self-hosted, macos, arm64] because the Docker image can't run on macOS ARM.
# Now that the job itself runs on ubuntu-latest, the Docker image works natively.
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
Expand All @@ -93,7 +98,7 @@ jobs:
name: Canvas (Next.js)
needs: changes
if: needs.changes.outputs.canvas == 'true'
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
defaults:
run:
working-directory: canvas
Expand All @@ -119,12 +124,11 @@ jobs:
name: Shellcheck (E2E scripts)
needs: changes
if: needs.changes.outputs.scripts == 'true'
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run shellcheck on tests/e2e/*.sh
# `ludeeus/action-shellcheck` is a Docker action (Linux-only). We rely
# on shellcheck being pre-installed on the self-hosted runner instead.
# shellcheck is pre-installed on ubuntu-latest GitHub-hosted runners.
run: |
if ! command -v shellcheck >/dev/null 2>&1; then
echo "::error::shellcheck is not installed on the runner"
Expand All @@ -135,7 +139,7 @@ jobs:

canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
needs: [changes, canvas-build]
# Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
Expand Down Expand Up @@ -181,24 +185,19 @@ jobs:
name: Python Lint & Test
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
defaults:
run:
working-directory: workspace
steps:
- uses: actions/checkout@v4
# setup-python@v5 cannot write to /Users/runner (GitHub-hosted path) on
# the self-hosted macOS arm64 runner (user: <runner-user>) and also hits
# EACCES on /usr/local/bin due to macOS SIP. Skip it — Homebrew installs
# Python 3.11 at /opt/homebrew/opt/python@3.11 which is already on PATH.
- name: Verify Python 3.11 (Homebrew)
run: |
export PATH="/opt/homebrew/opt/python@3.11/bin:/opt/homebrew/bin:$PATH"
python3.11 --version
echo "/opt/homebrew/opt/python@3.11/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
- run: pip3.11 install -r requirements.txt pytest pytest-asyncio pytest-cov
- run: python3.11 -m pytest --tb=short -q --cov=. --cov-report=term-missing
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
cache-dependency-path: workspace/requirements.txt
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
- run: WORKSPACE_ID=ci-placeholder python -m pytest --tb=short -q --cov=. --cov-report=term-missing

# SDK + plugin validation moved to standalone repo:
# github.com/Molecule-AI/molecule-sdk-python
1 change: 0 additions & 1 deletion workspace-server/internal/handlers/a2a_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
Expand Down
75 changes: 0 additions & 75 deletions workspace-server/internal/handlers/a2a_proxy_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,11 @@ package handlers

import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
Expand Down Expand Up @@ -281,76 +276,6 @@ func parseUsageFromA2AResponse(body []byte) (inputTokens, outputTokens int64) {
return 0, 0
}

// 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
}

// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
// Returns (0, 0, false) when the key is absent or contains no non-zero values.
func readUsageMap(m map[string]json.RawMessage) (inputTokens, outputTokens int64, ok bool) {
Expand Down
2 changes: 1 addition & 1 deletion workspace-server/internal/handlers/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ func (h *ActivityHandler) Report(c *gin.Context) {
}

// LogActivity inserts an activity log and optionally broadcasts via WebSocket.
func LogActivity(ctx context.Context, broadcaster *events.Broadcaster, params ActivityParams) {
func LogActivity(ctx context.Context, broadcaster broadcasterLogger, params ActivityParams) {
reqJSON, reqErr := json.Marshal(params.RequestBody)
if reqErr != nil {
log.Printf("LogActivity: failed to marshal request_body for %s: %v", params.WorkspaceID, reqErr)
Expand Down
76 changes: 0 additions & 76 deletions workspace-server/internal/handlers/mcp_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ import (
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
Expand Down Expand Up @@ -460,80 +458,6 @@ func (h *MCPHandler) toolRecallMemory(ctx context.Context, workspaceID string, a
return string(b), nil
}

// 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
}

// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────

// mcpResolveURL returns a routable URL for a workspace's A2A server.
//
// Resolution order:
Expand Down
2 changes: 1 addition & 1 deletion workspace-server/internal/handlers/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type PluginsHandler struct {
docker *client.Client // Docker client for container operations
restartFunc func(string) // auto-restart workspace after install/uninstall
runtimeLookup RuntimeLookup // workspace_id → runtime (optional)
sources *plugins.Registry // pluggable install sources (local, github, clawhub, …)
sources plugins.SourceResolver // pluggable install sources (local, github, clawhub, …)
}

// NewPluginsHandler constructs a PluginsHandler with the default source
Expand Down
86 changes: 86 additions & 0 deletions workspace-server/internal/handlers/ssrf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package handlers

// ssrf.go — shared SSRF defence (URL + IP range validation) and path
// traversal checks used across multiple handler files.
//
// validateRelPath is used by templates.go file operations.
// isSafeURL and isPrivateOrMetadataIP are used by a2a_proxy.go (A2A dispatch)
// and mcp_tools.go (MCP session routing).

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
}
Loading
Loading