fix(core#2129): forward-time SSRF validation for chat file workspace URLs #3169
Reference in New Issue
Block a user
Delete Branch "fix/2129-chat-files-ssrf-2316"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Closes core#2129 (chat-file workspace-URL SSRF + credential forwarding). The chat upload + download forwarders previously relied solely on registration-time
validateAgentURLto gate outbound destinations — a stale DB row or a since-tightened SSRF policy could let a workspace-controlled URL receive theAuthorization: Bearer <platform_inbound_secret>header.This commit adds forward-time
isSafeURLvalidation inresolveWorkspaceForwardCreds— the shared helper bothUploadandDownloaduse to assemble the outbound URL. The check fires BEFORE the secret is attached, so a workspace pointing at metadata, loopback, or a non-http scheme is rejected at the forward boundary with HTTP 400, no bearer header attached.Source
The implementation is a clean re-apply of Kimi's 2026-06-02 commit
399579d9(Kimi was quota-dead; the branchfix/2134-chat-files-forward-ssrf-2316was 21 days stale and had no PR). Cherry-picked onto current main5d7ea08e(post PR#3168 merge); one trivial conflict in the comment block ofchat_files.goresolved by keeping the HEAD side (more detailed comment with the #2316 reference).Files changed (88 lines, 2 files)
workspace-server/internal/handlers/chat_files.go(+9 / -4)resolveWorkspaceForwardCreds: after readingworkspaces.url, callisSafeURL(wsURL). If unsafe, log + return 400 with constant body — no secret is fetched, no bearer header is constructed.workspace-server/internal/handlers/chat_files_test.go(+75 / -0)TestChatUpload_RejectsMetadataWorkspaceURL—169.254.169.254→ 400TestChatUpload_RejectsNonHTTPWorkspaceURL—file://→ 400TestChatDownload_RejectsMetadataWorkspaceURL— same for downloadTestChatDownload_RejectsNonHTTPWorkspaceURL— same for downloadOut of scope (per the issue body, but a separate concern)
The issue body also lists the write-path bypass —
workspace.go:383-388(external workspace create) andorg_import.go:245-248(external org import) — whereworkspaces.urlis written withoutvalidateAgentURL. Since the forward-time layer in this PR blocks exploitation at the secret-attachment boundary, the write-path concern is defense-in-depth rather than the critical path. Filing as a follow-up would be appropriate; not bundled here per the scope discipline (one item = one focused change).Independence from the red #3164 deployment surface
Pure workspace-server handler + test fix. No concierge / MCP delivery / heartbeat / identity-gate / platform-side touched. Safe to merge on the core-lane (will stage behind the CTO qa/security gate per PM's standing rule).
Tests
go test ./internal/handlers/...— 38.6s, all 4 new SSRF tests + every existing handler test pass.go build ./...+go vet ./...— clean.Gate
unit-tests+e2efor workspace-serverREQUEST_CHANGES on
a4982ca04f(target=main).Blocking SSRF gap: the new
resolveWorkspaceForwardCredscheck validates the initialworkspaces.urlbefore fetching/attaching the inbound secret, which correctly blocks direct metadata/loopback/non-http URLs. HoweverChatFilesHandlerstill uses a defaulthttp.Client, so Upload/Download will follow HTTP redirects without re-runningisSafeURLon the redirect target.That leaves a bypass: a workspace URL that passes
isSafeURLcan return a redirect tohttp://169.254.169.254/..., loopback, or another forbidden target. Download GET will follow it; Upload can also follow method-preserving 307/308 redirects. This is still a forward-time SSRF path even though the original URL was safe.Required fix: add redirect handling for the chat-files forward client/path, either disabling redirects or using
CheckRedirectto validate every redirect target withisSafeURLand fail closed with the same constant 400-style surface. Add regression coverage proving upload/download reject a safe URL that redirects to metadata/loopback and do not forward to the unsafe destination. DNS rebinding remains a broader sharedisSafeURLlimitation, but the redirect bypass is directly in this PR's forward path and should be closed here.REQUEST_CHANGES on
a4982ca0.5-axis review: correctness/security are not complete for the forward-time SSRF boundary; robustness/performance/readability are otherwise fine for the direct URL validation and tests.
Blocking finding:
Fix shape: use the same safe outbound transport policy for chat-files forwards: no automatic redirects or revalidate redirect targets, and enforce safeDialer/safeDialControl at dial time; add regression coverage for redirect-to-169.254.169.254 (and ideally DNS/dial-time internal resolution). The noted write-path URL validation gap can remain a separate PR, but this forward-time bypass is in #3169's claimed boundary.
The chat-files http.Client (NewChatFilesHandler) still used the default net/http behaviour on two SSRF axes that the front-door isSafeURL gate does not cover: 1. Redirect chase. A workspace URL that passes isSafeURL on the front-door check can 302 to an unsafe target (metadata endpoint, loopback, link-local). The default http.Client follows up to 10 redirects, forwarding the Authorization: Bearer <inbound-secret> header to the unsafe target. 2. DNS rebind. The front-door isSafeURL check happens on the URL string, before any DNS lookup. A workspace that controls its DNS can return a safe IP at preflight and a different IP at dial time, bypassing the front-door policy. Both axes were already covered in the transcript-proxy (transcript.go:73 ErrUseLastResponse, transcript.go:91-98 safeDialControl). This commit ports the same pattern to chat-files. Changes: - http.Client.CheckRedirect = http.ErrUseLastResponse (no chase) - http.Client.Transport.DialContext = safeDialer().DialContext (POST-resolution IP guard via safeDialControl / isSafeURL) Tests: - TestChatDownload_RedirectToMetadata_NotFollowed: a SAFE workspace URL that 302s to a metadata endpoint must not be followed; metadata hit counter must be zero. - TestChatUpload_RedirectToMetadata_NotFollowed: same regression for POST. Uses the metadata hit counter as the load-bearing signal (POST-based 3xx responses have empty bodies under Go's http.Redirect, so w.Code assertions don't apply). Refs: core#2129, RC 13395, CR2 13396.APPROVED on
20a1ea924b.5-axis review: correctness/security issue from my prior RC 13397 is resolved; robustness/readability are good; performance impact is limited to using the existing safe dialer policy on chat-file forwards. The PR targets main and is mergeable. Functional required checks visible on this head are green; qa/security bot re-fire is being handled separately.
Review notes:
I could not run Go tests in this container because go is not installed, but the reviewed diff matches the reported ./internal/handlers green run.
APPROVE on
20a1ea924b(target=main).Re-review for RC 13396: resolved. Direct unsafe workspace URLs are rejected in resolveWorkspaceForwardCreds before readOrLazyHealInboundSecret, so metadata/loopback/non-http inputs return 400 before any inbound secret fetch/attach.
The chat-files client now refuses redirects with CheckRedirect/http.ErrUseLastResponse, so a safe initial workspace URL cannot redirect the platform to metadata/loopback while carrying the inbound bearer. Upload and download redirect tests prove the metadata target is not hit. The client also uses safeDialer().DialContext, matching the transcript proxy pattern, so the actual dialed IP is guarded against DNS rebinding after preflight validation.
Coverage retains upload/download direct unsafe URL tests and adds upload/download redirect-to-metadata regressions. No secret material is logged; the unsafe initial URL response remains a constant 400. Functional required contexts are green on this head; qa/security bot re-fire state is separate from this code verdict.