Compare commits

...

3 Commits

Author SHA1 Message Date
core-be 32f3d712d3 fix(handlers): add mutex protection to ssrf test-flag package vars
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Waiting to run
CI / all-required (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 47s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1m5s
CI / Detect changes (pull_request) Successful in 2m18s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 53s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 40s
E2E API Smoke Test / detect-changes (pull_request) Successful in 3m57s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 44s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 2m6s
qa-review / approved (pull_request) Failing after 46s
security-review / approved (pull_request) Failing after 40s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m47s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m54s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m31s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m31s
CI / Canvas (Next.js) (pull_request) Failing after 11m37s
CI / Platform (Go) (pull_request) Failing after 22m42s
gate-check-v3 / gate-check (pull_request) Successful in 16s
sop-checklist / all-items-acked (pull_request) Successful in 22s
sop-tier-check / tier-check (pull_request) Successful in 27s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Failing after 1m55s
Cherry-pick of hotfix/offsec-015-org-isolation commit 1d3d202f onto this branch.

ssrfCheckEnabled and testAllowLoopback are package-level bools mutated
by test setup functions and read by production SSRF validation code.
With -race, concurrent tests reading these vars while another test is
writing triggers data races. Fix: add sync.RWMutex protection.

mc#race-fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 12:43:01 +00:00
core-be 8753bab60f fix(router): register BroadcastHandler for POST /broadcast (OFFSEC-015)
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 23s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 36s
CI / Detect changes (pull_request) Successful in 59s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 21s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m12s
Harness Replays / detect-changes (pull_request) Successful in 33s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m18s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 20s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 31s
gate-check-v3 / gate-check (pull_request) Failing after 50s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m27s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m28s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 2m15s
qa-review / approved (pull_request) Failing after 37s
security-review / approved (pull_request) Failing after 24s
sop-checklist / all-items-acked (pull_request) Successful in 21s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m5s
sop-tier-check / tier-check (pull_request) Successful in 25s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m48s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m34s
Harness Replays / Harness Replays (pull_request) Successful in 11s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m32s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 1m33s
CI / Python Lint & Test (pull_request) Successful in 8m43s
CI / all-required (pull_request) Failing after 9m58s
CI / Platform (Go) (pull_request) Failing after 10m14s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6m41s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7m36s
CI / Canvas (Next.js) (pull_request) Successful in 17m58s
CI / Canvas Deploy Reminder (pull_request) Successful in 3s
OFFSEC-015 requires the POST /workspaces/:id/broadcast endpoint to be
wired in the router. workspace_broadcast.go exists but was not registered
in Setup(), so all broadcast requests would 404.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 06:42:02 +00:00
core-be 5e7f68ef8e fix(handlers): add rows.Err() checks to 9 handlers missing them
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 24s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 32s
Harness Replays / detect-changes (pull_request) Successful in 21s
CI / Detect changes (pull_request) Successful in 50s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 56s
E2E API Smoke Test / detect-changes (pull_request) Successful in 58s
gate-check-v3 / gate-check (pull_request) Successful in 33s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 40s
qa-review / approved (pull_request) Failing after 25s
security-review / approved (pull_request) Failing after 25s
sop-tier-check / tier-check (pull_request) Successful in 24s
Harness Replays / Harness Replays (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m32s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3m48s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m26s
CI / Python Lint & Test (pull_request) Successful in 7m44s
CI / Platform (Go) (pull_request) Successful in 11m54s
CI / Canvas (Next.js) (pull_request) Successful in 12m11s
CI / Canvas Deploy Reminder (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 12m19s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
Fixes silent DB scan errors in:
- approvals.go: ListAll and List pending-approval loops
- channels.go: TelegramWebhook channel lookup loop
- discovery.go: queryPeerMaps peer-workspace loop
- events.go: List and ListByWorkspace event loops
- memory.go: List workspace-memory loop
- schedules.go: History schedule-run loop
- tokens.go: List token loop
- workspace_restart.go: Pause and Resume descendant loops

All follow the established pattern: log the error with context, return
HTTP 200 with whatever rows were successfully scanned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 05:45:03 +00:00
11 changed files with 75 additions and 9 deletions
@@ -120,6 +120,9 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("ListAll pending approvals rows error: %v", err)
}
c.JSON(http.StatusOK, approvals)
}
@@ -159,6 +162,9 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("List approvals rows error workspace=%s: %v", workspaceID, err)
}
c.JSON(http.StatusOK, approvals)
}
@@ -514,6 +514,9 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
candidates = append(candidates, row)
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels Webhook rows error channel_type=%s: %v", channelType, err)
}
if targetSlug != "" {
// [slug] routing — match against config username (lowercased)
@@ -348,6 +348,9 @@ func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{},
result = append(result, peer)
}
if err := rows.Err(); err != nil {
log.Printf("queryPeerMaps rows error: %v", err)
}
return result, nil
}
@@ -49,6 +49,9 @@ func (h *EventsHandler) List(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("Events List rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -87,5 +90,8 @@ func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("Events ListByWorkspace rows error workspace=%s: %v", workspaceID, err)
}
c.JSON(http.StatusOK, events)
}
@@ -89,14 +89,18 @@ func newTestBroadcaster() *events.Broadcaster {
// for the duration of the test, so httptest.NewServer's loopback URLs
// don't trip the SSRF guard. The 169.254 metadata, RFC-1918, TEST-NET,
// CGNAT, and link-local guards stay active — only 127.0.0.0/8 and ::1
// are relaxed. Always paired with t.Cleanup to restore; multiple
// parallel tests won't race because Go test flips it sequentially per
// test unless t.Parallel() is used, and these tests don't parallelize.
// are relaxed. Protected by loopbackMu so concurrent tests don't race.
func allowLoopbackForTest(t *testing.T) {
t.Helper()
loopbackMu.Lock()
prev := testAllowLoopback
testAllowLoopback = true
t.Cleanup(func() { testAllowLoopback = prev })
t.Cleanup(func() {
loopbackMu.Lock()
defer loopbackMu.Unlock()
testAllowLoopback = prev
})
loopbackMu.Unlock()
}
// expectBudgetCheck adds the sqlmock expectation for the budget-check
@@ -54,6 +54,9 @@ func (h *MemoryHandler) List(c *gin.Context) {
entry.Value = json.RawMessage(value)
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Memory List rows error workspace=%s: %v", workspaceID, err)
}
c.JSON(http.StatusOK, entries)
}
@@ -325,6 +325,9 @@ func (h *ScheduleHandler) History(c *gin.Context) {
e.Request = json.RawMessage(reqStr)
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
log.Printf("Schedule History rows error schedule=%s workspace=%s: %v", scheduleID, workspaceID, err)
}
c.JSON(http.StatusOK, entries)
}
+30 -5
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
)
// devModeAllowsLoopback reports whether the SSRF defence should permit
@@ -35,13 +36,20 @@ func devModeAllowsLoopback() bool {
// loopback URLs and fake hostnames (*.example) don't trigger SSRF
// rejections. Production code never mutates this.
var ssrfCheckEnabled = true
var ssrfMu sync.RWMutex
// setSSRFCheckForTest overrides ssrfCheckEnabled for the duration of a test
// and returns a restore function. Use with defer in *_test.go only.
func setSSRFCheckForTest(enabled bool) func() {
ssrfMu.Lock()
defer ssrfMu.Unlock()
prev := ssrfCheckEnabled
ssrfCheckEnabled = enabled
return func() { ssrfCheckEnabled = prev }
return func() {
ssrfMu.Lock()
defer ssrfMu.Unlock()
ssrfCheckEnabled = prev
}
}
// isSafeURL validates that a URL resolves to a publicly-routable address,
@@ -54,9 +62,22 @@ func setSSRFCheckForTest(enabled bool) func() {
// the same VPC and register by their VPC-private IP. Metadata endpoints,
// loopback, link-local, and TEST-NET stay blocked in every mode.
func isSafeURL(rawURL string) error {
if !ssrfCheckEnabled {
// Capture both test-flag states under lock before any validation logic.
// Holding only ssrfMu here is sufficient because isPrivateOrMetadataIP
// (which reads testAllowLoopback) is called after this block releases the
// lock; we snapshot testAllowLoopback into a local variable so the
// two mutexes are never held simultaneously.
ssrfMu.RLock()
enabled := ssrfCheckEnabled
ssrfMu.RUnlock()
if !enabled {
return nil
}
loopbackMu.RLock()
allowLoopback := testAllowLoopback
loopbackMu.RUnlock()
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
@@ -69,7 +90,7 @@ func isSafeURL(rawURL string) error {
return fmt.Errorf("empty hostname")
}
if ip := net.ParseIP(host); ip != nil {
if (ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback()) || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
if (ip.IsLoopback() && !allowLoopback && !devModeAllowsLoopback()) || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
return fmt.Errorf("forbidden loopback/unspecified/link-local IP: %s", ip)
}
if isPrivateOrMetadataIP(ip) {
@@ -89,7 +110,7 @@ func isSafeURL(rawURL string) error {
if ip == nil {
continue
}
if (ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback()) || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
if (ip.IsLoopback() && !allowLoopback && !devModeAllowsLoopback()) || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
return fmt.Errorf("hostname %s resolves to forbidden link-local/loopback IP: %s", host, ip)
}
if isPrivateOrMetadataIP(ip) {
@@ -108,6 +129,7 @@ func isSafeURL(rawURL string) error {
// The 169.254 metadata, RFC-1918, TEST-NET, CGNAT, and link-local
// guards are NOT relaxed by this flag — only loopback.
var testAllowLoopback = false
var loopbackMu sync.RWMutex
// isPrivateOrMetadataIP returns true for IPs that must not be reached via A2A.
//
@@ -167,7 +189,10 @@ func isPrivateOrMetadataIP(ip net.IP) bool {
// ::1 (loopback) — treat as blocked here too for defense-in-depth,
// unless tests have opted into loopback via testAllowLoopback OR
// MOLECULE_ENV is a dev value (mirrors the v4 relaxation above).
if ip.IsLoopback() && !testAllowLoopback && !devModeAllowsLoopback() {
loopbackMu.RLock()
allowLB := testAllowLoopback
loopbackMu.RUnlock()
if ip.IsLoopback() && !allowLB && !devModeAllowsLoopback() {
return true
}
// Link-local fe80::/10 — always blocked.
@@ -67,6 +67,9 @@ func (h *TokenHandler) List(c *gin.Context) {
}
tokens = append(tokens, t)
}
if err := rows.Err(); err != nil {
log.Printf("Token List rows error workspace=%s: %v", workspaceID, err)
}
c.JSON(http.StatusOK, gin.H{
"tokens": tokens,
@@ -650,6 +650,9 @@ func (h *WorkspaceHandler) Pause(c *gin.Context) {
toPause = append(toPause, struct{ id, name string }{cid, cname})
}
}
if err := rows.Err(); err != nil {
log.Printf("Pause descendant lookup rows error workspace=%s: %v", id, err)
}
}
// Stop containers and mark all as paused. StopWorkspaceAuto routes
@@ -731,6 +734,9 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
toResume = append(toResume, ws)
}
}
if err := rows.Err(); err != nil {
log.Printf("Resume descendant lookup rows error workspace=%s: %v", id, err)
}
}
// Re-provision all
@@ -224,6 +224,10 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
wsAuth.POST("/delegations/record", delh.Record)
wsAuth.POST("/delegations/:delegation_id/update", delh.UpdateStatus)
// Workspace broadcast (OFFSEC-015 org isolation)
bch := handlers.NewBroadcastHandler(broadcaster)
wsAuth.POST("/broadcast", bch.Broadcast)
// Traces (Langfuse proxy)
trh := handlers.NewTracesHandler()
wsAuth.GET("/traces", trh.List)