@@ -13,6 +13,7 @@ import (
1313 "mime"
1414 "net/http"
1515 neturl "net/url"
16+ "os"
1617 "path"
1718 "strings"
1819 "time"
@@ -49,6 +50,11 @@ const (
4950 attachmentsTotalTimeFactor = 3
5051)
5152
53+ // mediaHostAllowlistEnv names extra hosts authorized to serve incoming media,
54+ // comma-separated, for deployments serving blobs off the CRM host (ActiveStorage
55+ // redirect mode, CDN). The default anchor is the event's own postback host.
56+ const mediaHostAllowlistEnv = "MEDIA_HOST_ALLOWLIST"
57+
5258// maxBackoff caps the exponential backoff between retries so a large
5359// AI_CALL_RETRY_BASE_MS or retry count cannot balloon the wait.
5460const maxBackoff = 5 * time .Second
@@ -333,12 +339,26 @@ func (a *aiAdapter) buildFileParts(ctx context.Context, req *model.A2ARequest) [
333339 budgetCtx , cancelBudget := context .WithTimeout (ctx , attachmentsTotalTimeFactor * perDownload )
334340 defer cancelBudget ()
335341
342+ // Built once per turn: the client closes over the authorized hosts.
343+ hosts := allowedMediaHosts (req .PostbackURL )
344+ client := a .mediaClient (hosts )
345+
336346 parts := make ([]model.JSONRPCPart , 0 , len (req .Attachments ))
337347 remaining := maxAttachmentsTotalBytes
338348 for i , att := range req .Attachments {
339349 if att .URL == "" {
340350 continue
341351 }
352+ if err := checkMediaURL (att .URL , hosts ); err != nil {
353+ slog .Warn ("pipeline.ai.attachment.blocked_url" ,
354+ "contact_id" , req .ContactID ,
355+ "conversation_id" , req .ConversationID ,
356+ "file_type" , att .FileType ,
357+ "error" , err ,
358+ "hint" , "set " + mediaHostAllowlistEnv + " when blobs are served off the CRM host" ,
359+ )
360+ continue
361+ }
342362 if budgetCtx .Err () != nil {
343363 slog .Warn ("pipeline.ai.attachment.budget_exhausted" ,
344364 "contact_id" , req .ContactID ,
@@ -360,12 +380,15 @@ func (a *aiAdapter) buildFileParts(ctx context.Context, req *model.A2ARequest) [
360380 )
361381 break
362382 }
363- data , respContentType , err := a . downloadAttachment (budgetCtx , att .URL , perDownload , limit )
383+ data , respContentType , err := downloadAttachment (budgetCtx , client , att .URL , perDownload , limit )
364384 if err != nil {
385+ // Status separates the common 404-from-an-expired-signed-link case from
386+ // an unreachable host; they logged identically before.
365387 slog .Warn ("pipeline.ai.attachment.download_failed" ,
366388 "contact_id" , req .ContactID ,
367389 "conversation_id" , req .ConversationID ,
368390 "file_type" , att .FileType ,
391+ "status" , statusOf (err ),
369392 "error" , err ,
370393 )
371394 continue
@@ -412,24 +435,88 @@ func (a *aiAdapter) attachmentTimeout() time.Duration {
412435 return d
413436}
414437
415- // downloadAttachment GETs the URL with the adapter's client and the given timeout,
416- // reading at most limit bytes. It returns the body and the response Content-Type so
417- // the caller can decide what the bytes actually are.
418- func (a * aiAdapter ) downloadAttachment (ctx context.Context , url string , timeout time.Duration , limit int ) ([]byte , string , error ) {
438+ // allowedMediaHosts returns the hostnames authorized to serve this event's media.
439+ // An empty set authorizes nothing: failing closed beats fetching a URL the caller
440+ // chose.
441+ func allowedMediaHosts (postbackURL string ) map [string ]struct {} {
442+ hosts := make (map [string ]struct {}, 2 )
443+ if u , err := neturl .Parse (postbackURL ); err == nil {
444+ if h := strings .ToLower (u .Hostname ()); h != "" {
445+ hosts [h ] = struct {}{}
446+ }
447+ }
448+ for _ , h := range strings .Split (os .Getenv (mediaHostAllowlistEnv ), "," ) {
449+ if h = strings .ToLower (strings .TrimSpace (h )); h != "" {
450+ hosts [h ] = struct {}{}
451+ }
452+ }
453+ return hosts
454+ }
455+
456+ // checkMediaURL reports why a media URL must not be fetched, or nil when it may be.
457+ func checkMediaURL (rawURL string , hosts map [string ]struct {}) error {
458+ u , err := neturl .Parse (rawURL )
459+ if err != nil {
460+ return fmt .Errorf ("unparseable media url: %w" , err )
461+ }
462+ if scheme := strings .ToLower (u .Scheme ); scheme != "http" && scheme != "https" {
463+ return fmt .Errorf ("scheme %q is not allowed for media" , u .Scheme )
464+ }
465+ host := strings .ToLower (u .Hostname ())
466+ if host == "" {
467+ return errors .New ("media url has no host" )
468+ }
469+ if _ , ok := hosts [host ]; ! ok {
470+ return fmt .Errorf ("host %q is not authorized to serve media for this event" , host )
471+ }
472+ return nil
473+ }
474+
475+ // mediaClient shares the adapter's transport but re-runs checkMediaURL on every
476+ // redirect hop, so an authorized host cannot 302 the download onto an internal one.
477+ func (a * aiAdapter ) mediaClient (hosts map [string ]struct {}) * http.Client {
478+ return & http.Client {
479+ Transport : a .client .Transport ,
480+ CheckRedirect : func (req * http.Request , via []* http.Request ) error {
481+ if len (via ) >= 10 {
482+ return errors .New ("stopped after 10 redirects" )
483+ }
484+ return checkMediaURL (req .URL .String (), hosts )
485+ },
486+ }
487+ }
488+
489+ // httpStatusError carries the status of a non-200 media response.
490+ type httpStatusError struct { status int }
491+
492+ func (e * httpStatusError ) Error () string { return fmt .Sprintf ("unexpected status %d" , e .status ) }
493+
494+ // statusOf returns the HTTP status of a download error, or 0 if there was no response.
495+ func statusOf (err error ) int {
496+ var se * httpStatusError
497+ if errors .As (err , & se ) {
498+ return se .status
499+ }
500+ return 0
501+ }
502+
503+ // downloadAttachment GETs the URL with the given client and timeout, reading at most
504+ // limit bytes. It returns the body and the response Content-Type.
505+ func downloadAttachment (ctx context.Context , client * http.Client , url string , timeout time.Duration , limit int ) ([]byte , string , error ) {
419506 dlCtx , cancel := context .WithTimeout (ctx , timeout )
420507 defer cancel ()
421508
422509 httpReq , err := http .NewRequestWithContext (dlCtx , http .MethodGet , url , nil )
423510 if err != nil {
424511 return nil , "" , fmt .Errorf ("new_request: %w" , err )
425512 }
426- resp , err := a . client .Do (httpReq )
513+ resp , err := client .Do (httpReq )
427514 if err != nil {
428515 return nil , "" , fmt .Errorf ("do: %w" , err )
429516 }
430517 defer resp .Body .Close ()
431518 if resp .StatusCode != http .StatusOK {
432- return nil , "" , fmt . Errorf ( "unexpected status %d" , resp .StatusCode )
519+ return nil , "" , & httpStatusError { status : resp .StatusCode }
433520 }
434521 // +1 so an exactly-at-cap read is distinguishable from an oversize one.
435522 data , err := io .ReadAll (io .LimitReader (resp .Body , int64 (limit )+ 1 ))
0 commit comments