feat: attach-time processing – transcode/resize images and resolve URLs at message add time#2685
Conversation
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Two medium-severity issues found in the new attach-time processing pipeline (pkg/chat/attach.go):
- SSRF —
fetchRemoteImageuseshttp.DefaultClientwith no IP/redirect filtering; user-supplied URLs can reach internal network services or cloud metadata endpoints. - Unbounded binary read — PDF and unknown-binary branches in
processFilePartcallos.ReadFilewith no size cap, unlike the text branch which checksMaxInlineFileSize. A multi-GB file would be read fully into memory.
Reviewed: pkg/chat/attach.go (new), pkg/app/app.go, pkg/cli/runner.go.
| return nil, "", "", fmt.Errorf("create request: %w", err) | ||
| } | ||
|
|
||
| resp, err := http.DefaultClient.Do(req) |
There was a problem hiding this comment.
[MEDIUM] SSRF: fetchRemoteImage uses http.DefaultClient with no network filtering
fetchRemoteImage issues an HTTP GET to any URL the caller supplies using the default Go HTTP client, which follows up to 10 redirects and performs no filtering on private/internal IP ranges:
resp, err := http.DefaultClient.Do(req)A user can supply a URL such as:
http://169.254.169.254/latest/meta-data/— AWS IMDS (returns IAM credentials)http://10.0.0.1//http://localhost:8080/— internal services accessible from the agent's network- A redirect chain that terminates at an internal address
Since the agent processes user-provided MessagePartTypeImageURL parts, this is a direct SSRF vector for anyone who can send messages to the agent. The 10-second timeout and 20 MB cap do not mitigate the access itself.
Suggested fix: Use a custom http.Transport with a DialContext that rejects connections to loopback, link-local, and RFC-1918 addresses, or at minimum restrict the URL scheme allowlist and add a CheckRedirect policy that prevents cross-origin redirects to private IP space.
There was a problem hiding this comment.
Fixed in 247c919. Added safeHTTPClient with a custom DialContext that blocks connections to loopback (127.0.0.0/8, ::1), link-local/metadata endpoints (169.254.0.0/16, fe80::/10), RFC-1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and IPv6 unique-local (fc00::/7). CheckRedirect applies the same filtering to redirect destinations. Two new SSRF tests added.
There was a problem hiding this comment.
Thanks for the thorough fix! The implementation looks solid:
✅ Comprehensive IP range blocking — covers all the critical ranges (loopback, link-local/metadata, RFC-1918, IPv6 ULA)
✅ DNS resolution at dial time — prevents bypasses via DNS rebinding or hostnames that resolve to private IPs
✅ Redirect filtering — applies the same checks on every redirect hop via CheckRedirect, preventing chain-based SSRF
✅ Test coverage — the two SSRF tests verify both loopback and metadata endpoint blocking
The test helper SetFetchHTTPClientForTest is a clean way to allow tests to reach local httptest servers without weakening the production path. This resolves the SSRF concern completely.
| // PDF and other supported binary types — read verbatim. | ||
| // The !IsTextFile guard ensures that binary formats whose extension | ||
| // is unknown but content is ASCII-printable are not incorrectly inlined. | ||
| data, err := os.ReadFile(absPath) |
There was a problem hiding this comment.
[MEDIUM] No size cap on local binary file reads — potential OOM with large PDF/binary attachments
The PDF branch and the default (unknown binary) branch both call os.ReadFile with no size limit:
case mimeType == "application/pdf" || (IsSupportedMimeType(mimeType) && !IsTextFile(absPath)):
data, err := os.ReadFile(absPath) // no size capdefault:
data, err := os.ReadFile(absPath) // no size capA user who attaches a multi-GB PDF (or any large binary file) will cause the agent to read the entire file into memory before passing it on. The text file branch (line 130) and the remote image fetch (via io.LimitReader) both have explicit caps — this inconsistency looks like an oversight.
Contrast with text branch:
case IsTextFile(absPath):
if fi.Size() > MaxInlineFileSize { // explicit cap ✔
return Document{}, nil, fmt.Errorf(...)
}Suggested fix: Add a fi.Size() > someMaxBinarySize guard before each os.ReadFile call in the non-image binary branches. A reasonable upper bound might be attachMaxRemoteBytes (20 MB) to match the remote-image cap, or a separate constant for local binary files.
There was a problem hiding this comment.
Fixed in 247c919. Added fi.Size() > MaxInlineBinarySize (20 MB) guard before os.ReadFile in all binary branches (image pre-read, PDF, and unknown binary). Consistent with the text branch cap. New test TestProcessAttachment_BinaryFileTooLarge_Error verifies the error path.
There was a problem hiding this comment.
Perfect, thanks for the thorough fix! Verified that all three binary branches (image pre-read line 217, PDF line 228, and unknown binary line 251) now have the fi.Size() > MaxInlineBinarySize guard before os.ReadFile. The 20 MB cap is well-chosen — matches attachMaxRemoteBytes so local and remote paths behave consistently, as your comment notes.
The sparse-file test is a nice touch — keeps the suite fast while still exercising the error path.
simonferquel
left a comment
There was a problem hiding this comment.
For now do not wire the attachment conversion into the cli and tui. We will start with supporting it only when used as a library.
…I/CLI wiring)
Adds pkg/chat/attach.go and pkg/chat/attach_test.go.
No changes to pkg/app or pkg/cli — wiring is intentionally deferred
until the library API is stable.
## ProcessAttachment / ProcessAttachmentWithMetadata
Converts a raw MessagePart into a fully-resolved chat.Document with
InlineData or InlineText. Called once when a message is assembled.
MessagePartTypeFile:
- MIME-first routing (precedes IsTextFile heuristic to avoid
misclassifying ASCII-content PDFs as text)
- image/* → ResizeImage transcode (bounded by MaxInlineBinarySize)
- application/pdf and other binary → verbatim InlineData
(bounded by MaxInlineBinarySize = 20 MB)
- text/* → ReadFileForInline InlineText (bounded by MaxInlineFileSize)
- unknown binary → verbatim InlineData (bounded)
MessagePartTypeImageURL:
- data: URIs decoded inline
- http(s):// fetched via safeHTTPClient with SSRF protection:
- blocks loopback (127.0.0.0/8, ::1)
- blocks link-local / metadata endpoints (169.254.0.0/16, fe80::/10)
- blocks RFC-1918 (10/8, 172.16/12, 192.168/16)
- blocks IPv6 unique-local (fc00::/7)
- DialContext + CheckRedirect both enforce the block
- DNS resolved with context via net.DefaultResolver.LookupHost
- bytes capped at 20 MB; goes through ResizeImage
MessagePartTypeDocument:
- images with InlineData transcoded; others passed through
- no inline content → error
ProcessAttachmentWithMetadata also returns *ImageResizeResult so callers
can emit a dimension note without a redundant second ResizeImage call.
SetFetchHTTPClientForTest allows tests to inject a plain http.Client to
reach loopback-bound httptest servers.
## Tests (25 cases)
JPEG/PNG passthrough, PNG-with-alpha, oversize image resized,
PDF passthrough, binary too large (sparse file), text/markdown inline,
missing file, nil inputs, data: URI (JPEG/PNG), non-base64 data: URI,
unsupported scheme, httptest server success path, httptest 404,
SSRF loopback block, SSRF metadata endpoint (169.254.169.254) block,
document passthrough (binary/text), document image transcode,
document no-content error, unsupported part type.
Part of docker#2595
Assisted-By: docker-agent
307e3d7 to
247c919
Compare
| // processImageURLPart handles MessagePartTypeImageURL. | ||
| // Supports: | ||
| // - data: URIs (data:<mime>;base64,<payload>) | ||
| // - http:// and https:// URLs (fetched with attachHTTPTimeout) |
There was a problem hiding this comment.
Let's not download things from the internet, we don't need to handle urls
There was a problem hiding this comment.
Done in 0472016. Removed fetchRemoteImage, safeHTTPClient, and the entire SSRF mitigation. processImageURLPart now returns an error for http(s):// URLs: "remote URLs are not supported; download the file locally first". data: URI decoding is retained (local data, no network). Added TestProcessAttachment_RemoteURL_Error to cover both schemes.
…a: URIs only Per rumpl's review: remove all http(s):// fetching from the attach-time pipeline. The library should not download things from the internet. Changes: - Remove fetchRemoteImage function entirely - Remove safeHTTPClient, newSafeHTTPClient, privateIPNets, isPrivateIP, checkSafeHostCtx, SetFetchHTTPClientForTest (whole SSRF mitigation is now unnecessary) - Remove time, io, mime, net, net/http imports - processImageURLPart: data: URIs still decoded and transcoded as before; http(s):// URLs now return errors.New(...remote URLs are not supported...) - ProcessAttachment / ProcessAttachmentWithMetadata: context parameter kept on the public API (for forward compat) but marked _ since no I/O needs it - Remove network tests: TestProcessAttachment_HTTPS_Image_Via_HTTPTestServer, TestProcessAttachment_HTTP_Non200_Error, TestProcessAttachment_SSRF_* - Add TestProcessAttachment_RemoteURL_Error covering both http:// and https:// - MaxInlineBinarySize guard retained (local files, still valid) Assisted-By: docker-agent
Summary
Attach-time processing pipeline for the Phase 1 attachment system. Part of #2595.
Scope: library-only. This PR adds
pkg/chat/attach.goonly. Wiring into the TUI (pkg/app) and CLI (pkg/cli) is intentionally deferred until the library API is stable.What's added
pkg/chat/attach.go(new)Two new functions:
ProcessAttachment(ctx, part) (Document, error)— converts a rawMessagePartinto a fully-resolvedchat.DocumentwithInlineDataorInlineText. Called once when a message is assembled; never at inference time.ProcessAttachmentWithMetadata(ctx, part) (Document, *ImageResizeResult, error)— same, but also returns theImageResizeResultso callers can emit a dimension note without a redundant second resize call.Handles three input types:
MessagePartTypeFileIsTextFileheuristic to avoid misclassifying ASCII-content PDFs as text). Images →ResizeImagetranscode; PDF/other binary → verbatimInlineData;text/*→ReadFileForInline(InlineText). All binary reads capped atMaxInlineBinarySize(20 MB).MessagePartTypeImageURLdata:URIs decoded inline;http(s)://fetched viasafeHTTPClient(see security section). Bytes capped at 20 MB; result goes throughResizeImage.MessagePartTypeDocumentInlineDatatranscoded; other already-resolved documents passed through unchanged. No inline content → error.Security
SSRF protection in
fetchRemoteImage:The custom
safeHTTPClientblocks connections to private/reserved IP ranges at both dial time and on each redirect hop:127.0.0.0/8,::1/128169.254.0.0/16,fe80::/1010.0.0.0/8,172.16.0.0/12,192.168.0.0/16fc00::/7DNS resolution uses
net.DefaultResolver.LookupHostwith context (satisfiesnoctxlinter). BothDialContextandCheckRedirectenforce the block.Unbounded read protection:
fi.Size() > MaxInlineBinarySizeguard added before allos.ReadFilecalls for binary files (images, PDFs, unknown binary).Tests (25 cases in
pkg/chat/attach_test.go)MaxImageDimension)InlineTextdata:URI JPEG and PNG →InlineDatadata:URI → errorhttp://fetch viahttptest.Server(success + HTTP 404) — usesSetFetchHTTPClientForTestto bypass SSRF filter for loopback test servers127.0.0.1(loopback) → error with "private/reserved"169.254.169.254(AWS/GCP/Azure metadata) → error with "private/reserved"MessagePartTypeDocumentpassthrough (binary, text) and image transcodeWhat's NOT changed
pkg/app/app.go— unchanged from upstream/mainpkg/cli/runner.go— unchanged from upstream/mainchat.DocumentviaconvertDocument, already in feat: Phase 1 attachment system – chat.Document, pkg/attachment, per-provider convertDocument #2639)Part of #2595