Skip to content

fix(EVO-2178): validate incoming media URLs before fetching them#6

Merged
gomessguii merged 2 commits into
developfrom
fix/EVO-2178-review-ssrf-guard
Jul 21, 2026
Merged

fix(EVO-2178): validate incoming media URLs before fetching them#6
gomessguii merged 2 commits into
developfrom
fix/EVO-2178-review-ssrf-guard

Conversation

@gomessguii

@gomessguii gomessguii commented Jul 21, 2026

Copy link
Copy Markdown
Member

Review follow-up to EVO-2180 (PR #5, already merged). Findings from the EVO-2178 review of the merged media path.

🔴 SSRF with exfiltration (critical)

Attachment URLs arrive inside the /events payload and the adapter fetched them verbatim — no scheme check, no host check, and the client followed redirects. So /events doubled as a read primitive aimed by its caller: the bytes of any URL reachable from this service were base64-encoded into the A2A call, whose destination (outgoing_url) comes from the same payload. Reproduced end to end against a local metadata-style endpoint, directly and through a 302.

  • checkMediaURL pins the scheme to http/https and requires the host to be one the CRM is known to serve blobs from: the host of the event's own postback_url (already mandatory in MessageEvent.Validate, so no new config for the default topology) plus whatever MEDIA_HOST_ALLOWLIST names, for deployments serving blobs off an S3/MinIO/CDN host. Unauthorized media is skipped and logged; the text reply is unaffected, like every other media failure here.
  • The download client re-runs that check on every redirect hop, so an authorized host cannot walk the fetch onto an internal address.

Also in this PR

  • BOT_RUNTIME_SECRET is now required. It was read with os.Getenv, and SecretMiddleware compares the header against it, so an empty value authenticated every caller that simply omitted the header.
  • TTL on the media buffer key. ClearState remains the normal cleanup; the TTL only stops a turn that dies before reaching it from leaving media URLs in Redis forever.
  • Download failures log the HTTP status. The common production case is a 404 from a signed link that expired while the queue was backed up, and it read identically to an unreachable host.
  • .github/workflows/ci.yml. Nothing ran the Go suite on a PR, which is how test/e2e stayed non-compiling from EVO-558 until EVO-2180.

Verification

go build ./... · go vet ./... · go test ./... — green, e2e included, against a throwaway Redis. The new SSRF tests were mutation-checked: with the guard disabled, 3 of the 5 fail (the URL check, the redirect re-check, the fail-closed default), so they are not vacuous.

⚠️ Deployment note: the media allowlist is fail-closed. A deployment that serves blobs from a host other than the CRM (ActiveStorage redirect mode → S3/MinIO, or a CDN) must set MEDIA_HOST_ALLOWLIST, or that media is silently skipped.

Part of EVO-2178 · follow-up to EVO-2180.

🤖 Generated with Claude Code

Summary by Sourcery

Harden media handling in the AI adapter to prevent SSRF and ensure only trusted hosts and schemes are used when downloading attachments, while preserving text responses when media is blocked or fails.

Bug Fixes:

  • Validate incoming attachment URLs against the CRM postback host and an optional MEDIA_HOST_ALLOWLIST, rejecting non-http/https schemes and unauthorized hosts to close the SSRF exfiltration vector.
  • Re-run media URL validation on every redirect during attachment downloads so allowlisted hosts cannot redirect to internal or otherwise unauthorized addresses.
  • Require BOT_RUNTIME_SECRET to be set at startup so the bot runtime endpoint is no longer effectively unauthenticated when the secret is empty.
  • Record the HTTP status code for failed attachment downloads to distinguish common 404 cases from network-level failures.
  • Add a TTL to the Redis media buffer key so orphaned attachment state does not persist indefinitely when a turn dies before normal cleanup.

Enhancements:

  • Extend the A2ARequest model and pipeline service to carry the CRM postback URL into the AI adapter as the anchor for media host allowlisting.

CI:

  • Introduce a CI workflow that builds, vets, and tests the Go codebase on pushes and pull requests using a Redis service container to support repository tests.

Tests:

  • Add SSRF-focused tests that cover foreign-host attachments, allowlisted blob hosts, redirect-based attacks, non-http/https schemes, and fail-closed behavior when no authorized host is configured.
  • Update existing AI adapter media tests to include the required PostbackURL in A2A requests.

Review follow-up to EVO-2180. Attachment URLs arrive inside the /events
payload and the adapter fetched them verbatim, so the endpoint doubled as a
read primitive aimed by its caller: the bytes of any URL reachable from this
service were base64-encoded into the A2A call, whose destination
(outgoing_url) comes from the same payload. Reproduced end to end against a
local metadata-style endpoint, both directly and through a 302.

- checkMediaURL pins the scheme to http/https and requires the host to be one
  the CRM is known to serve blobs from: the postback URL's host (already
  mandatory in MessageEvent.Validate, so no new config for the default
  topology) plus whatever MEDIA_HOST_ALLOWLIST names, for deployments serving
  blobs off an S3/MinIO/CDN host. Unauthorized media is skipped and logged;
  the text reply is unaffected, like every other media failure here.
- The download client re-runs that check on every redirect hop, so an
  authorized host cannot walk the fetch onto an internal address.
- BOT_RUNTIME_SECRET becomes required. It was read with os.Getenv, and
  SecretMiddleware compares the header against it, so an empty value
  authenticated every caller that simply omitted the header.
- The media buffer key gets a TTL. ClearState remains the normal cleanup; the
  TTL only stops a turn that dies before reaching it from leaving media URLs
  in Redis forever.
- Attachment download failures now log the HTTP status: the common production
  case is a 404 from a signed link that expired while the queue was backed up,
  and it read identically to an unreachable host.
- Adds .github/workflows/ci.yml. Nothing ran the Go suite on a PR, which is
  how test/e2e stayed non-compiling from EVO-558 until EVO-2180.
@sourcery-ai

sourcery-ai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR hardens media handling in the AI adapter to prevent SSRF/exfiltration, enforces a non-empty bot runtime secret, bounds the lifetime of Redis media buffers, improves logging of media download failures, and adds a CI workflow that runs the Go build/vet/test suite on pushes and PRs.

Sequence diagram for validated media download with SSRF protection

sequenceDiagram
    participant PipelineService
    participant AIAdapter
    participant MediaClient
    participant MediaHost

    PipelineService->>AIAdapter: runAIStage(...)
    AIAdapter->>AIAdapter: allowedMediaHosts(PostbackURL)
    AIAdapter->>AIAdapter: mediaClient(hosts)

    loop for each Attachment
        AIAdapter->>AIAdapter: checkMediaURL(Attachment.URL, hosts)
        alt URL not allowed
            AIAdapter->>AIAdapter: slog.Warn("pipeline.ai.attachment.blocked_url")
        else URL allowed
            AIAdapter->>MediaClient: downloadAttachment(ctx, client, URL, timeout, limit)
            activate MediaClient
            MediaClient->>MediaHost: GET URL
            alt Redirect response
                MediaHost-->>MediaClient: 3xx Location
                MediaClient->>MediaClient: CheckRedirect(...)
                MediaClient->>AIAdapter: checkMediaURL(newURL, hosts)
                alt checkMediaURL fails
                    MediaClient-->>AIAdapter: error
                else checkMediaURL ok
                    MediaClient->>MediaHost: GET newURL
                end
            else 200 OK
                MediaHost-->>MediaClient: 200 OK body
                MediaClient-->>AIAdapter: data, contentType
            else Non-200
                MediaHost-->>MediaClient: status != 200
                MediaClient-->>AIAdapter: &httpStatusError
            end
            deactivate MediaClient

            alt error from downloadAttachment
                AIAdapter->>AIAdapter: statusOf(err)
                AIAdapter->>AIAdapter: slog.Warn("pipeline.ai.attachment.download_failed")
            else success
                AIAdapter->>AIAdapter: append file part
            end
        end
    end
Loading

Flow diagram for media host allowlist and Redis attachment TTL

flowchart LR
    subgraph MediaAllowlist
        A[PostbackURL] -->|hostname| B[allowedMediaHosts]
        C[MEDIA_HOST_ALLOWLIST] -->|comma-separated hosts| B
        B --> D{hosts map empty?}
        D -->|yes| E[All attachments skipped<br/>fail-closed]
        D -->|no| F["checkMediaURL(rawURL, hosts)"]
        F -->|invalid/unauthorized| G[blocked_url log]
        F -->|ok| H[downloadAttachment]
    end

    subgraph RedisBufferTTL
        I[AppendAttachments] --> J[RPush attachBufferKey]
        J --> K[Expire attachBufferKey<br/>attachBufferTTL]
    end
Loading

File-Level Changes

Change Details Files
Validate and constrain incoming media URLs and their redirects before downloading attachments, and adjust the media download path accordingly.
  • Compute an allowlisted set of media hosts per event from the postback URL and MEDIA_HOST_ALLOWLIST environment variable.
  • Introduce checkMediaURL to enforce http/https schemes and require hosts to be in the allowlist, failing closed when no host is authorized.
  • Build a dedicated media HTTP client that shares the adapter transport but re-validates each redirect target via CheckRedirect.
  • Refactor downloadAttachment to be a standalone function that takes the media client, wraps non-200 statuses in a typed error, and expose a statusOf helper to extract status codes for logging.
  • Gate attachment processing in buildFileParts on checkMediaURL, logging blocked URLs and skipping unauthorized media while preserving the text reply.
  • Log HTTP status codes on attachment download failures using the typed error.
pkg/ai/service/ai_adapter.go
pkg/ai/service/ai_adapter_media_test.go
pkg/ai/model/a2a.go
pkg/pipeline/service/pipeline_service.go
pkg/ai/service/ai_adapter_ssrf_test.go
Ensure Redis attachment buffers are not left indefinitely by adding a TTL to the attachment list key.
  • After appending attachments with RPush, set an expiration on the attachment buffer key using attachBufferTTL.
  • Define attachBufferTTL as 10 minutes, with comments documenting its relationship to debounce windows and CRM-signed media URL TTLs.
pkg/pipeline/repository/redis_pipeline_repository.go
Require BOT_RUNTIME_SECRET to be set and non-empty during configuration loading to prevent unauthenticated /events access.
  • Replace direct os.Getenv usage for BOT_RUNTIME_SECRET with a mustGetEnv call that errors when the variable is missing or empty.
  • Document the security implications in comments: empty secret previously authenticated callers that omitted the header, turning /events into an outbound-request gadget.
internal/config/config.go
Add continuous integration to run Go build, vet, and tests (including Redis-backed tests) on PRs and main/develop branch pushes.
  • Create a GitHub Actions workflow that runs on pull_request and push to develop/main.
  • Configure a Redis service container and REDIS_TEST_URL environment for tests.
  • Add steps to checkout code, set up Go, and run go build ./..., go vet ./..., and go test ./....
.github/workflows/ci.yml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="pkg/ai/service/ai_adapter.go" line_range="500-510" />
<code_context>
+// transport (connection pool) but re-runs checkMediaURL on every redirect hop: an
+// allowlisted host that answers 302 must not be able to walk the download onto a
+// link-local or internal address.
+func (a *aiAdapter) mediaClient(hosts map[string]struct{}) *http.Client {
+	return &http.Client{
+		Transport: a.client.Transport,
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			if len(via) >= 10 {
+				return errors.New("stopped after 10 redirects")
+			}
+			return checkMediaURL(req.URL.String(), hosts)
+		},
+	}
+}
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** mediaClient drops http.Client-level settings from a.client; consider reusing or cloning the existing client.

This creates a new http.Client that only reuses a.client.Transport and adds CheckRedirect, dropping any other configuration on a.client (e.g., CookieJar, Timeout, etc.) for media downloads.

Consider either:
- Basing mediaClient on a.client and overriding only CheckRedirect, or
- Clearly documenting that mediaClient is intentionally configured differently.

This avoids surprises if a.client is later updated (e.g., global Timeout or Jar) with the expectation that media downloads share those settings.

```suggestion
func (a *aiAdapter) mediaClient(hosts map[string]struct{}) *http.Client {
	// Clone the base client so media downloads inherit all configuration
	// (timeouts, cookie jar, etc.) but enforce our own redirect policy.
	clientCopy := *a.client
	clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		if len(via) >= 10 {
			return errors.New("stopped after 10 redirects")
		}
		return checkMediaURL(req.URL.String(), hosts)
	}
	return &clientCopy
}
```
</issue_to_address>

### Comment 2
<location path="pkg/pipeline/repository/redis_pipeline_repository.go" line_range="89-93" />
<code_context>
 		values = append(values, b)
 	}
-	return r.rdb.RPush(ctx, attachBufferKey(contactID, conversationID), values...).Err()
+	key := attachBufferKey(contactID, conversationID)
+	if err := r.rdb.RPush(ctx, key, values...).Err(); err != nil {
+		return err
+	}
+	// ClearState is the normal cleanup, but any turn that dies without reaching it
+	// (panic, killed pod, a Redis blip on the Del) would otherwise leave this key —
+	// and the media URLs in it — in Redis forever. The TTL is a floor, not the
+	// debounce window: it only has to outlive the longest possible turn.
+	return r.rdb.Expire(ctx, key, attachBufferTTL).Err()
 }

</code_context>
<issue_to_address>
**suggestion (bug_risk):** TTL failure now makes AppendAttachments fail; consider whether this should be best-effort instead of hard-fail.

With this change, any transient Expire failure (e.g., network blip, Redis failover) will cause AppendAttachments to return an error even though RPush has already succeeded and the data is durably written.

Since attachBufferTTL is a safety net rather than a correctness requirement, consider treating Expire as best-effort: log on failure but still return nil when RPush succeeds, so the main write path doesn’t gain new failure modes solely due to TTL issues.

```suggestion
	// ClearState is the normal cleanup, but any turn that dies without reaching it
	// (panic, killed pod, a Redis blip on the Del) would otherwise leave this key —
	// and the media URLs in it — in Redis forever. The TTL is a floor, not the
	// debounce window: it only has to outlive the longest possible turn.
	//
	// Expire is best-effort: a failure here should not turn a successful append
	// into an error, since the TTL is a safety net rather than a correctness
	// requirement.
	_ = r.rdb.Expire(ctx, key, attachBufferTTL).Err()
	return nil
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +500 to +510
func (a *aiAdapter) mediaClient(hosts map[string]struct{}) *http.Client {
return &http.Client{
Transport: a.client.Transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return checkMediaURL(req.URL.String(), hosts)
},
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): mediaClient drops http.Client-level settings from a.client; consider reusing or cloning the existing client.

This creates a new http.Client that only reuses a.client.Transport and adds CheckRedirect, dropping any other configuration on a.client (e.g., CookieJar, Timeout, etc.) for media downloads.

Consider either:

  • Basing mediaClient on a.client and overriding only CheckRedirect, or
  • Clearly documenting that mediaClient is intentionally configured differently.

This avoids surprises if a.client is later updated (e.g., global Timeout or Jar) with the expectation that media downloads share those settings.

Suggested change
func (a *aiAdapter) mediaClient(hosts map[string]struct{}) *http.Client {
return &http.Client{
Transport: a.client.Transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return checkMediaURL(req.URL.String(), hosts)
},
}
}
func (a *aiAdapter) mediaClient(hosts map[string]struct{}) *http.Client {
// Clone the base client so media downloads inherit all configuration
// (timeouts, cookie jar, etc.) but enforce our own redirect policy.
clientCopy := *a.client
clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return checkMediaURL(req.URL.String(), hosts)
}
return &clientCopy
}

Comment on lines +89 to +93
// ClearState is the normal cleanup, but any turn that dies without reaching it
// (panic, killed pod, a Redis blip on the Del) would otherwise leave this key —
// and the media URLs in it — in Redis forever. The TTL is a floor, not the
// debounce window: it only has to outlive the longest possible turn.
return r.rdb.Expire(ctx, key, attachBufferTTL).Err()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): TTL failure now makes AppendAttachments fail; consider whether this should be best-effort instead of hard-fail.

With this change, any transient Expire failure (e.g., network blip, Redis failover) will cause AppendAttachments to return an error even though RPush has already succeeded and the data is durably written.

Since attachBufferTTL is a safety net rather than a correctness requirement, consider treating Expire as best-effort: log on failure but still return nil when RPush succeeds, so the main write path doesn’t gain new failure modes solely due to TTL issues.

Suggested change
// ClearState is the normal cleanup, but any turn that dies without reaching it
// (panic, killed pod, a Redis blip on the Del) would otherwise leave this key —
// and the media URLs in it — in Redis forever. The TTL is a floor, not the
// debounce window: it only has to outlive the longest possible turn.
return r.rdb.Expire(ctx, key, attachBufferTTL).Err()
// ClearState is the normal cleanup, but any turn that dies without reaching it
// (panic, killed pod, a Redis blip on the Del) would otherwise leave this key —
// and the media URLs in it — in Redis forever. The TTL is a floor, not the
// debounce window: it only has to outlive the longest possible turn.
//
// Expire is best-effort: a failure here should not turn a successful append
// into an error, since the TTL is a safety net rather than a correctness
// requirement.
_ = r.rdb.Expire(ctx, key, attachBufferTTL).Err()
return nil

@gomessguii
gomessguii merged commit 76f23b1 into develop Jul 21, 2026
5 checks passed
@gomessguii
gomessguii deleted the fix/EVO-2178-review-ssrf-guard branch July 21, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant