feat(webhooks): accept inbound media on POST /v1/webhooks/llm (sync mode) - #1538
Open
justintruong29 wants to merge 4 commits into
Open
feat(webhooks): accept inbound media on POST /v1/webhooks/llm (sync mode)#1538justintruong29 wants to merge 4 commits into
justintruong29 wants to merge 4 commits into
Conversation
Adds FetchInboundMedia, which turns caller-supplied URLs into local temp files for the agent pipeline. Guards, in the order they apply: - SSRF validation up front, plus a redirect-following client that re-validates the resolved destination IP at every hop's dial, so a redirect into a private range or a DNS rebind is refused mid-fetch. Not the non-redirecting client: that one returns a 0-byte body with no error for any presigned or CDN URL. - A 10-item count cap, rejected rather than truncated. - A content sniff cross-check against the declared Content-Type, since that header is attacker-controlled and persistMedia trusts it verbatim to pick a routing path. - A 25 MB size cap per file. - An image header gate at 50 megapixels, which also rejects an image/* file that does not decode at all. SanitizeImage fails open, so without this a bogus file would reach the agent under the caller's declared MIME. - A 512 MB process-wide byte budget. The per-webhook rate limiter bounds nothing here: allow() returns true whenever rpm <= 0 and there is no non-zero default, leaving a 150 GB worst case. Any failed item fails the whole request, and every item is attempted so the resulting status comes from a fixed severity order rather than from array position. Failure detail is a closed enum with no free-text field: SSRF errors carry the resolved internal IP and url.Error carries the caller's full URL, either of which would turn the endpoint into a port-scanning oracle once it reached a prompt or a response body. Exports security.RedactURL and security.ErrBlockedDial. The sentinel lets callers classify a blocked dial with errors.Is instead of matching error text, which is brittle in the unsafe direction. Moves the media MIME allowlist to internal/webhooks as the single source of truth for both webhook directions and deletes the internal/http copy.
Wires the fetcher into the sync path. input becomes optional when media is present, since an image with no caption is a legitimate request; a request carrying neither is still rejected. Media tags are prepended to the agent message. This is load-bearing, not cosmetic: enrichImageIDs rewrites an existing <media:image> tag and never inserts one, so when an agent has a dedicated read_image provider the images are not attached to the main LLM at all and the tag is the only signal that an image exists. Without it the run is a silent no-op — no error, no log, wrong answer, and only on that configuration. Cleanup lives inside the lane closure, not in a handler defer. lane.Submit runs its closure in a detached goroutine and returns immediately; the handler then selects on the request-derived context while the run uses context.WithoutCancel. A deferred cleanup in the handler would delete the files on client disconnect while the agent was still running, leaving it to answer about a tag with no image behind it for minutes. The two paths that never reach the closure — a Submit error and the idempotency early return — clean up themselves. One deadline computed before the fetch covers both the download and the agent run, so a sync call cannot outlive webhook_sync_timeout_sec. Left on the bare request context the fetch was bounded only by five minutes per item, which ten items turn into most of an hour that no operator setting could shorten. media with mode=async returns 400 before anything is downloaded and before any row is enqueued. The async payload is frozen before the mode dispatch and stored verbatim, so a downloaded file has no way to reach the worker; an accepted request would run with no media and no tag, then answer confidently about an image nobody sent, with status done and nothing in last_error. Audit rows store media URLs with the query string stripped. A presigned URL's signature is a bearer credential and request_payload is readable via the admin calls endpoint for 30 days.
Covers the item shape, the allowed MIME types, and the constraints an integrator cannot guess: sync-only with a 400 on async, all-or-nothing failure, deliberately coarse failure detail, the shared deadline, the ~20-attachment process-wide concurrency bound that the byte budget implies, and the shared uploads directory a caller lands in when it omits user_id. Records that the URL query string is stripped everywhere the value is retained or forwarded — audit row, media tag, logs — since the media tag reaches the LLM provider and persisted session history. Notes that animated WebP is rejected, as the decoder handles still WebP only, and adds the 413 row the /message error table has always been missing.
The failure this exists to prevent: a middleware puts the image URL inside input as text, gets a plausible-looking answer, and never learns the image was never fetched. Nothing downloads, no media tag is built, and the model sees a string that looks like a link. Covers the wrong/right payload side by side, what a URL must satisfy (public, no auth header — the gateway sends none, allowlisted Content-Type, alive at POST time), when to pass a platform CDN URL directly versus proxying it through your own storage, and which status codes are worth retrying.
clark-cant
reviewed
Aug 27, 2026
clark-cant
left a comment
Contributor
There was a problem hiding this comment.
🔍 Maintainer Review (github-maintain cron)
Verdict: Approve — well-engineered feature with strong security posture.
Mandatory Gates
- Duplicate/prior implementation: Clear — no duplicate PR or issue found for webhook inbound media.
- Project standards: Follows existing webhook handler patterns, SSRF policy, i18n catalog conventions, and audit payload structure.
- Strategic necessity: Clear value — closes a real gap where webhook callers (Zalo OA, Messenger middleware) cannot send images the way Telegram/WebSocket clients already can.
Assessment
Strengths:
- SSRF defense-in-depth: per-redirect-hop dial re-validation, DNS rebinding protection, closed-enum failure responses (no oracle)
- All-or-nothing semantics with deterministic severity-ordered status — correct for M2M API
- 50MP image header gate before full decode prevents memory bomb (10×25MB×30000×30000 → 3.6GB RGBA)
- Process-wide 512MB byte budget with proper reserve/commit/release lifecycle
- Async+media correctly rejected (400) rather than silently broken
- URL query string stripping in audit/media-tag/logs protects presigned URL credentials
- Media tag injection is load-bearing for file-ref vision mode — good catch
- Cleanup ownership correctly placed inside lane closure (not handler defer)
- 42 new tests with race-clean, including non-obvious behavioral assertions (file size on redirect, both RunRequest.Media AND media tag, disconnect-doesnt-delete)
Risk level: Medium (scope is 15 files / +2252 lines, but well-scoped to the feature; no breaking changes to existing contracts)
No Critical or Important findings. The PR body demonstrates exceptional attention to security edge cases and failure modes. Documentation in docs/webhooks.md is thorough and integrator-friendly.
Recommendation: Safe to merge. CI is green, mergeState is CLEAN, no conflicting reviews.
Posted by github-maintain automation — 2026-08-27T12:40Z
clark-cant
requested changes
Aug 27, 2026
clark-cant
left a comment
Contributor
There was a problem hiding this comment.
Review\n\nImportant — idempotent retries fetch media before the idempotency reservation.\n\nhandle calls FetchInboundMedia before handleSync, but reserveIdempotentCall runs only inside handleSync. Consequently, a duplicate request with the same idempotency key re-downloads every media[] URL before replaying the already-completed response. If the original request used a short-lived signed URL, a legitimate retry can now fail during the fetch (or consume the global download budget) instead of returning the stored result. It also repeats a caller-controlled outbound request despite idempotency.\n\nPlease move the idempotency reservation/replay decision ahead of media fetching, or otherwise ensure a matching stored completed call is returned before any URL fetch. Preserve cleanup ownership for only the newly reserved run and add regression coverage proving an idempotent replay neither calls the media fetcher nor requires the original URL to remain reachable.\n\nThe SSRF, MIME, size, redirect, cleanup, and deadline work is thoughtful; CI is green, but this retry-contract regression needs resolution before merge.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
POST /v1/webhooks/llmaccepts text only today, so a webhook caller cannot send an image the way Telegram or the WebSocket client can. This adds an optionalmedia[{url, filename}]array, sync mode only: the gateway downloads each URL to a temp file and hands local paths toagent.RunRequest.Media, where every other channel already converges.Motivating case: a middleware sits between a platform callback (Zalo OA, Messenger) and the gateway, so the native channel is never in the path, and the platform's image URL has nowhere to go.
Why the agent side needs no change
RunRequest.Media []bus.MediaFilealready exists and everything downstream is channel-agnostic —enrichInputMediabranches only onlen(req.Media) > 0. The gap was entirely at the request boundary.Design decisions
media[]by URL, not by path. Every existing request boundary (WSchat.go, MCPchat_runner.go) passes local paths because the caller pre-uploads viaPOST /v1/media/upload— which sits behindrequireAuthneeding a user/admin role, while a webhook caller holds only awh_bearer or an HMAC key.chat.goalso passesitem.Pathstraight intopersistMediaunvalidated, which is safe only because that caller is an authenticated user. A caller-supplied path on a webhook would be an arbitrary local file read.NewRedirectFollowingSafeClient, notNewSafeClient. The latter setsCheckRedirecttohttp.ErrUseLastResponse, so a presigned or CDN URL silently yields a 0-byte body with no error. The redirect-following client re-validates the resolved destination IP at every hop's dial, which also closes DNS rebinding. The test for this asserts on file size, not justerr == nil— with the wrong client the failure is silent.Sync only;
mode=async+media[]returns 400. Not deferred work, a deliberate rejection:buildAuditPayloadfreezesrequestPayloadbefore the mode dispatch andhandleAsyncstores it verbatim, so server-computed local paths have no way to reach the worker. An accepted request would run with no media and no tag, then answer confidently about an image nobody sent, withstatus: "done"and nothing inlast_error.Media tags are load-bearing, not cosmetic.
enrichImageIDsusesreplaceFirstMediaTag— it rewrites an existing<media:image>tag and never inserts one. When an agent has a dedicatedread_imageprovider (file-ref vision mode), images are not attached to the main LLM at all, so the tag is the model's only signal. No tag means a silent no-op: no error, no log, wrong answer — and only on that configuration, so it works on a dev box and fails at a customer.Failure detail is a closed enum. SSRF errors embed the hostname and the resolved internal IP;
url.Errorembeds the caller's full URL plus "connection refused" vs "i/o timeout". Any of that reaching the response body or the prompt turns this endpoint into an internal-network and port-scanning oracle. Detail goes toslogonly, and a reflection test pins that the failure struct has no free-text field.Guards
Content-Type. That header is attacker-controlled andpersistMediatrusts it verbatim to decide routing./messagecap so both directions of the API agree on one number.image.DecodeConfigbefore any full decode.SanitizeImagecallsimaging.Openbefore checking dimensions, so ten 25 MB items each declaring 30000x30000 would decode to ~3.6 GB of RGBA. The gate also rejects animage/*file that does not decode at all, becauseSanitizeImagefails open — it logs and proceeds with the original bytes under the caller's declared MIME, which is fine for authenticated channels and not fine here.allow()returns true wheneverrpm <= 0andRateLimitPerMinhas no non-zero default, leaving a 600 rpm x 10 items x 25 MB worst case.Semantics
All-or-nothing. Any failed item fails the whole request and the agent is never invoked. Partial success was dropped deliberately — the chat-channel precedent it would copy annotates the message for a human to read, and there is no human on a machine-to-machine API.
Deterministic status. Every item is attempted even after one fails, and the status comes from a fixed severity order (
ssrf > mime_denied > too_large > download_failed > budget_exhausted) rather than from array position.Cleanup is owned by the run.
lane.Submitruns its closure in a detached goroutine and returns immediately; the handler then selects on the request-derived context while the run usescontext.WithoutCancel. Adeferin the handler would delete the files on client disconnect whileag.Runwas still executing —persistMediafails its copy, logs a warning, drops the ref, and the agent burns minutes answering about a tag with no image behind it. Cleanup therefore lives inside the closure, with the two paths that never reach it (aSubmiterror, the idempotency early return) cleaning up themselves. Each has a test.One deadline covers download and run, so a sync call cannot outlive
gateway.webhook_sync_timeout_sec.URL query strings are stripped wherever the value is retained or forwarded — the audit row (
request_payloadis readable via the admin calls endpoint for 30 days), the<media:image url="...">tag (which reaches the LLM provider and persisted session history), and every log line. A presigned URL's signature is a bearer credential.Supporting changes
security.RedactURLandsecurity.ErrBlockedDialexported. The sentinel lets callers classify a blocked dial witherrors.Israther than matching error text — text matching is brittle in the unsafe direction, since a reworded message would silently downgrade a real SSRF block to a generic failure.internal/webhooksas one source of truth; the private copy ininternal/httpis deleted andprobeMediaURLpoints at the exported map.catalog_ko.goalso gains the three existingMsgWebhookMedia*keys it was missing.docs/webhooks.md: themedia[]contract, a middleware integration guide, and the 413 row the/v1/webhooks/messageerror table has always been missing (the code has returned it sincewebhooks_message.go).Testing
42 new tests — 25 in
internal/webhooks, 17 ininternal/http— all green under-race.go build ./...,go build -tags sqliteonly ./..., andgo vet ./...clean on this base.Tests that pin the non-obvious behaviour rather than just executing code: the redirect test asserts file size; the core wiring test asserts both
RunRequest.Mediaand<media:imagein the message, since asserting only the former passes while file-ref vision mode is silently broken; the disconnect test asserts from inside the agent stub that the file still exists after the handler has returned; and one test provessecurity.ErrBlockedDialsurvives thenet.OpError→url.Errorwrapping, which is what makeserrors.Isviable there.Surface parity:
ui/webN/A — the admin test endpoint (POST /v1/webhooks/{id}/test) and its UI dialog stay text-only in this change, stated indocs/webhooks.md. No schema change, so no migration on either PG or SQLite. The only public-contract additions are the two exportedinternal/securitysymbols and the optionalmediarequest field.Out of scope
Outbound media in the sync response (
webhookLLMSyncRespdropsRunResult.Media),media[]on the admin test endpoint, and async support. Async is documented as rejected rather than deferred, with the reasoning kept so the same design is not rebuilt.Known limitation
A caller that omits
user_idlands in<workspace>/<agent_key>/.uploads/, shared with every other such caller. Documented, not changed — it is pre-existing behaviour of the upload path, not introduced here.