Compare commits

...

6 Commits

Author SHA1 Message Date
devops-engineer fd8113d593 Merge branch 'main' into cr2/sec-c-2130-transcript-ssrf
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
E2E Chat / detect-changes (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 7s
E2E Chat / E2E Chat (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 25s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request_target) Successful in 9s
qa-review / approved (pull_request_target) Failing after 7s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 14s
sop-checklist / review-refire (pull_request_target) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 23s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
Harness Replays / Harness Replays (pull_request) Successful in 9s
sop-checklist / all-items-acked (pull_request_target) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request_target) Failing after 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 35s
security-review / approved (pull_request_target) Failing after 14s
CI / Canvas (Next.js) (pull_request) Successful in 8s
CI / Canvas Deploy Status (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 58s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 58s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m4s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m46s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 24s
CI / Platform (Go) (pull_request) Failing after 5m33s
CI / all-required (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 4m35s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 8m40s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Has been cancelled
2026-06-06 16:30:42 +00:00
devops-engineer bce7e4a98c Merge branch 'main' into cr2/sec-c-2130-transcript-ssrf
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Python Lint & Test (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 16s
CI / Canvas (Next.js) (pull_request) Successful in 4s
E2E Chat / detect-changes (pull_request) Successful in 18s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / detect-changes (pull_request) Successful in 23s
CI / Canvas Deploy Status (pull_request) Has been skipped
E2E Chat / E2E Chat (pull_request) Successful in 3s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 20s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 12s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 17s
gate-check-v3 / gate-check (pull_request_target) Successful in 18s
security-review / approved (pull_request_target) Failing after 11s
sop-checklist / review-refire (pull_request_target) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 22s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
Harness Replays / Harness Replays (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 9s
sop-tier-check / tier-check (pull_request_target) Failing after 7s
qa-review / approved (pull_request_target) Failing after 26s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m32s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 15s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m22s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m52s
CI / Platform (Go) (pull_request) Failing after 3m17s
CI / all-required (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 27s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 5m10s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 11m8s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Has been cancelled
2026-06-06 13:46:12 +00:00
devops-engineer 3d50ec2b7f Merge branch 'main' into cr2/sec-c-2130-transcript-ssrf
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 3s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 15s
Harness Replays / detect-changes (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
E2E Chat / detect-changes (pull_request) Successful in 16s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 11s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 7s
security-review / approved (pull_request_target) Failing after 17s
gate-check-v3 / gate-check (pull_request_target) Successful in 18s
qa-review / approved (pull_request_target) Failing after 18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 33s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 15s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request_target) Failing after 13s
Harness Replays / Harness Replays (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 5s
E2E Chat / E2E Chat (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 56s
CI / Canvas Deploy Status (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 57s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m7s
CI / Platform (Go) (pull_request) Failing after 5m40s
CI / all-required (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 41s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 6m24s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 7m55s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Has been cancelled
2026-06-06 11:05:18 +00:00
Molecule AI Dev Engineer A (Kimi) 2f585ab183 fix(tests): stop setupTestDB from unconditionally disabling SSRF guard
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 6s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Failing after 2s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 12s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 4s
sop-checklist / review-refire (pull_request_target) Has been skipped
gate-check-v3 / gate-check (pull_request_target) Successful in 14s
sop-tier-check / tier-check (pull_request_target) Failing after 5s
CI / Canvas (Next.js) (pull_request) Successful in 1s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 32s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 1s
security-review / approved (pull_request_target) Failing after 25s
qa-review / approved (pull_request_target) Failing after 25s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 46s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 51s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m2s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 2m19s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m14s
CI / Platform (Go) (pull_request) Failing after 4m51s
CI / all-required (pull_request) Has been skipped
Issue #807: setupTestDB called setSSRFCheckForTest(false) for every test,
which silently overrode the SSRF guard in regression tests that explicitly
enable it (transcript_test.go and registry_test.go). The later call to
setSSRFCheckForTest(true) in those tests was being masked.

Change setupTestDB to preserve (not mutate) the existing SSRF state across
the test boundary. Tests that genuinely need SSRF disabled can call
setSSRFCheckForTest(false) inline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 19:03:52 +00:00
Molecule AI Dev Engineer A (Kimi) 352c47d028 fix(test): reorder SSRF enable after setupTestDB so tests run with guard ON (internal#807)
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Failing after 2s
CI / Python Lint & Test (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request_target) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
qa-review / approved (pull_request_target) Failing after 3s
security-review / approved (pull_request_target) Failing after 4s
sop-checklist / review-refire (pull_request_target) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 28s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 20s
CI / Detect changes (pull_request) Successful in 29s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 27s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 4s
sop-tier-check / tier-check (pull_request_target) Failing after 5s
Harness Replays / Harness Replays (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 3s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 35s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 23s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 53s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 56s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 2m38s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m18s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m17s
CI / Platform (Go) (pull_request) Successful in 4m0s
CI / all-required (pull_request) Successful in 2s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m16s
sop-tier-check / tier-check (pull_request_review) Successful in 5s
setupTestDB unconditionally calls setSSRFCheckForTest(false) and
registers t.Cleanup(restore). When the new SSRF regression tests
called setSSRFCheckForTest(true) *before* setupTestDB, the DB setup
overwrote the flag back to false, so the tests ran with SSRF
disabled and got 404/502 instead of the expected 400 BadRequest.

Fix: move setSSRFCheckForTest(true) to AFTER setupTestDB and switch
from defer restore() to t.Cleanup(restore) so the two restore
functions compose correctly in LIFO order.

Affected tests:\n- TestTranscript_RejectsCloudMetadataIP\n- TestTranscript_RejectsNonHTTPScheme\n- TestTranscript_RejectsMetadataHostname\n- TestTranscript_RejectsLinkLocalIPv6\n- TestTranscript_RejectsLoopbackURL\n- TestUpdateCard_RejectsMetadataURL\n- TestUpdateCard_RejectsNonHTTPScheme\n
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 11:37:35 +00:00
Molecule AI Code Reviewer (2) e3ec2a259f fix(security): harden transcript URL validation
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 3s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Failing after 2s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request_target) Successful in 3s
CI / Detect changes (pull_request) Successful in 18s
Harness Replays / detect-changes (pull_request) Successful in 14s
sop-checklist / review-refire (pull_request_target) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 16s
qa-review / approved (pull_request_target) Failing after 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
security-review / approved (pull_request_target) Failing after 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
CI / Canvas (Next.js) (pull_request) Successful in 1s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
E2E Chat / E2E Chat (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 1s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request_target) Has been cancelled
sop-checklist / all-items-acked (pull_request_target) Successful in 29s
sop-tier-check / tier-check (pull_request_review) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 56s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m1s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 1m20s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 2m26s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m4s
CI / Platform (Go) (pull_request) Failing after 5m18s
CI / all-required (pull_request) Has been skipped
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 6m52s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 7m15s
2026-06-02 20:33:24 +00:00
5 changed files with 103 additions and 104 deletions
@@ -94,11 +94,12 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
mockDB.Close()
})
// Disable SSRF checks for the duration of this test only. Restore
// the previous state via t.Cleanup so that TestIsSafeURL_* tests
// (which run with SSRF enabled) are not affected by state leak.
restore := setSSRFCheckForTest(false)
t.Cleanup(restore)
// Preserve SSRF state across this test. Individual tests that need
// SSRF disabled can call setSSRFCheckForTest(false) themselves.
// This prevents setupTestDB from silently overriding SSRF guards in
// regression tests that assert the guard is active (issue #807).
prevSSRF := ssrfCheckEnabled
t.Cleanup(func() { ssrfCheckEnabled = prevSSRF })
// The wsauth.platform_inbound_secret cache (#189) is package-level
// state in another package — without a reset between tests, a
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
@@ -873,6 +874,17 @@ func (h *RegistryHandler) UpdateCard(c *gin.Context) {
return // response already written
}
var card struct {
URL string `json:"url"`
}
if err := json.Unmarshal(payload.AgentCard, &card); err == nil && card.URL != "" {
if err := isSafeURL(card.URL); err != nil {
log.Printf("UpdateCard: workspace %s agent_card url rejected: %v", payload.WorkspaceID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace URL not allowed"})
return
}
}
agentCardStr := string(payload.AgentCard)
_, err := db.DB.ExecContext(c.Request.Context(), `
UPDATE workspaces SET agent_card = $2::jsonb, updated_at = now() WHERE id = $1
@@ -627,6 +627,57 @@ func TestUpdateCard_DBError(t *testing.T) {
}
}
func TestUpdateCard_RejectsMetadataURL(t *testing.T) {
t.Setenv("MOLECULE_ENV", "production")
mock := setupTestDB(t)
restore := setSSRFCheckForTest(true)
t.Cleanup(restore)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewRegistryHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"workspace_id":"ws-card","agent_card":{"name":"bad","url":"http://169.254.169.254/latest/meta-data/"}}`
c.Request = httptest.NewRequest("POST", "/registry/update-card", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateCard(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestUpdateCard_RejectsNonHTTPScheme(t *testing.T) {
mock := setupTestDB(t)
restore := setSSRFCheckForTest(true)
t.Cleanup(restore)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewRegistryHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"workspace_id":"ws-card","agent_card":{"name":"bad","url":"file:///etc/passwd"}}`
c.Request = httptest.NewRequest("POST", "/registry/update-card", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateCard(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestRegister_GuardAgainstResurrectingRemovedRow verifies the #73 fix:
// the ON CONFLICT UPSERT must carry a `WHERE status IS DISTINCT FROM 'removed'`
// clause so that a late heartbeat from a workspace that was just deleted
@@ -12,13 +12,10 @@ package handlers
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
@@ -66,7 +63,7 @@ func (h *TranscriptHandler) Get(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace URL"})
return
}
if err := validateWorkspaceURL(target); err != nil {
if err := isSafeURL(target.String()); err != nil {
log.Printf("transcript: workspace %s URL rejected: %v", workspaceID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace URL not allowed"})
return
@@ -121,58 +118,3 @@ func (h *TranscriptHandler) Get(c *gin.Context) {
}
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), body)
}
// validateWorkspaceURL enforces that the agent_card URL is safe to
// proxy to. agent_card is attacker-writable via /registry/register so
// any workspace-token holder could otherwise point the URL at cloud
// metadata (169.254.169.254), the Docker host, or other internal
// services reachable from the platform container.
//
// Policy:
// - scheme must be http or https (no file://, gopher://, ftp://, etc.)
// - host must be present
// - block cloud metadata endpoints (IMDS, GCP, Azure)
// - block link-local IPs (169.254/16 IPv4, fe80::/10 IPv6)
// - loopback is allowed — local dev runs workspaces on 127.0.0.1
// - Docker internal hostnames (host.docker.internal, *.molecule-core-net)
// are allowed; the whole threat model assumes the platform already
// trusts peers on that network
func validateWorkspaceURL(u *url.URL) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("unsupported scheme %q", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("empty host")
}
// Hostname blocklist (pre-IP-parse — these are usually resolved by
// the HTTP stack, not by us).
lower := strings.ToLower(host)
for _, banned := range []string{
"metadata.google.internal",
"metadata.azure.com",
"metadata",
} {
if lower == banned {
return fmt.Errorf("metadata hostname blocked: %s", host)
}
}
// IP-literal checks.
if ip := net.ParseIP(host); ip != nil {
// IMDS / cloud metadata.
if ip.String() == "169.254.169.254" {
return fmt.Errorf("cloud metadata endpoint blocked")
}
// Link-local: IPv4 169.254.0.0/16, IPv6 fe80::/10.
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return fmt.Errorf("link-local address blocked: %s", host)
}
// IPv6 unique local fd00::/8 — used by some IMDS implementations.
if ip.To4() == nil && len(ip) == net.IPv6len && ip[0] == 0xfd {
return fmt.Errorf("IPv6 unique-local address blocked: %s", host)
}
}
return nil
}
@@ -5,16 +5,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// urlParse is a tiny wrapper so table-driven tests can keep their lines short.
func urlParse(s string) (*url.URL, error) { return url.Parse(s) }
// expectWorkspaceURLLookup programs the sqlmock to answer the SELECT that
// TranscriptHandler.Get issues for `agent_card->>'url'`. Tests call this
// instead of inserting real rows (we use sqlmock — there's no DB).
@@ -50,6 +46,7 @@ func TestTranscript_WorkspaceNotFound(t *testing.T) {
}
func TestTranscript_ProxyForwardsAndReturnsBody(t *testing.T) {
allowLoopbackForTest(t)
mock := setupTestDB(t)
setupTestRedis(t)
h := NewTranscriptHandler()
@@ -90,6 +87,7 @@ func TestTranscript_ProxyForwardsAndReturnsBody(t *testing.T) {
}
func TestTranscript_ProxyPropagatesAllowlistedQueryParams(t *testing.T) {
allowLoopbackForTest(t)
mock := setupTestDB(t)
setupTestRedis(t)
h := NewTranscriptHandler()
@@ -115,12 +113,15 @@ func TestTranscript_ProxyPropagatesAllowlistedQueryParams(t *testing.T) {
}
// SSRF regression tests — see issue #272. agent_card->>'url' is attacker-
// writable via /registry/register so validateWorkspaceURL must reject
// writable via /registry/register so the production SSRF policy must reject
// link-local / cloud-metadata / non-http(s) targets before the outbound
// HTTP call fires.
func TestTranscript_RejectsCloudMetadataIP(t *testing.T) {
t.Setenv("MOLECULE_ENV", "production")
mock := setupTestDB(t)
restore := setSSRFCheckForTest(true)
t.Cleanup(restore)
setupTestRedis(t)
h := NewTranscriptHandler()
@@ -138,6 +139,8 @@ func TestTranscript_RejectsCloudMetadataIP(t *testing.T) {
func TestTranscript_RejectsNonHTTPScheme(t *testing.T) {
mock := setupTestDB(t)
restore := setSSRFCheckForTest(true)
t.Cleanup(restore)
setupTestRedis(t)
h := NewTranscriptHandler()
@@ -155,6 +158,8 @@ func TestTranscript_RejectsNonHTTPScheme(t *testing.T) {
func TestTranscript_RejectsMetadataHostname(t *testing.T) {
mock := setupTestDB(t)
restore := setSSRFCheckForTest(true)
t.Cleanup(restore)
setupTestRedis(t)
h := NewTranscriptHandler()
@@ -171,7 +176,10 @@ func TestTranscript_RejectsMetadataHostname(t *testing.T) {
}
func TestTranscript_RejectsLinkLocalIPv6(t *testing.T) {
t.Setenv("MOLECULE_ENV", "production")
mock := setupTestDB(t)
restore := setSSRFCheckForTest(true)
t.Cleanup(restore)
setupTestRedis(t)
h := NewTranscriptHandler()
@@ -187,45 +195,28 @@ func TestTranscript_RejectsLinkLocalIPv6(t *testing.T) {
}
}
// validateWorkspaceURL unit tests — pure function, no DB/Redis needed.
func TestValidateWorkspaceURL(t *testing.T) {
cases := []struct {
name string
raw string
wantErr bool
}{
{"http localhost allowed (dev)", "http://127.0.0.1:8000", false},
{"https public allowed", "https://agent.example.com", false},
{"docker internal allowed", "http://host.docker.internal:8000", false},
{"IMDS IP rejected", "http://169.254.169.254", true},
{"GCP metadata hostname rejected", "http://metadata.google.internal", true},
{"Azure metadata rejected", "http://metadata.azure.com", true},
{"file scheme rejected", "file:///etc/passwd", true},
{"gopher rejected", "gopher://internal:70/", true},
{"IPv6 link-local rejected", "http://[fe80::1]", true},
{"IPv4 link-local multicast rejected", "http://224.0.0.1", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
u, parseErr := urlParse(tc.raw)
if parseErr != nil && !tc.wantErr {
t.Fatalf("parse error: %v", parseErr)
}
if parseErr != nil {
return // unparseable URLs are rejected upstream; not this function's job
}
err := validateWorkspaceURL(u)
if tc.wantErr && err == nil {
t.Errorf("expected error for %q, got nil", tc.raw)
}
if !tc.wantErr && err != nil {
t.Errorf("expected OK for %q, got %v", tc.raw, err)
}
})
func TestTranscript_RejectsLoopbackURL(t *testing.T) {
t.Setenv("MOLECULE_ENV", "production")
mock := setupTestDB(t)
restore := setSSRFCheckForTest(true)
t.Cleanup(restore)
setupTestRedis(t)
h := NewTranscriptHandler()
wsID := expectWorkspaceURLLookup(mock, "http://127.0.0.1:8000/")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/transcript", nil)
h.Get(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for loopback target, got %d: %s", w.Code, w.Body.String())
}
}
func TestTranscript_UnreachableWorkspaceReturns502(t *testing.T) {
allowLoopbackForTest(t)
mock := setupTestDB(t)
setupTestRedis(t)
h := NewTranscriptHandler()
@@ -255,6 +246,7 @@ func TestTranscript_UnreachableWorkspaceReturns502(t *testing.T) {
// req.Header.Set("Authorization", c.GetHeader("Authorization"))
// This test verifies the fix and acts as a regression guard.
func TestTranscript_ForwardsAuthHeader(t *testing.T) {
allowLoopbackForTest(t)
mock := setupTestDB(t)
setupTestRedis(t)
h := NewTranscriptHandler()
@@ -308,6 +300,7 @@ func TestTranscript_ForwardsAuthHeader(t *testing.T) {
// request. The workspace will return 401 in this case, which the proxy
// faithfully relays — no silent upgrade of privilege.
func TestTranscript_NoAuthHeader_PassesThrough(t *testing.T) {
allowLoopbackForTest(t)
mock := setupTestDB(t)
setupTestRedis(t)
h := NewTranscriptHandler()