Skip to content

fix(security): SSRF egress guard on every server-side fetch (PT-03/07/11/17) - #987

Open
murdore wants to merge 1 commit into
releasefrom
fix/pt-ssrf-egress
Open

fix(security): SSRF egress guard on every server-side fetch (PT-03/07/11/17)#987
murdore wants to merge 1 commit into
releasefrom
fix/pt-ssrf-egress

Conversation

@murdore

@murdore murdore commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Independent of the other pentest PRs — targets release, merges in any order.

Four code paths dereference a URL that a template author, a lead payload or the
LLM can influence, and none checked where it pointed: MCP server URLs (hit at
flow-build time with decrypted tenant credentials attached), HttpRequestExecutor,
the reporting-webhook sender, and the three telephony recording downloads.

Adds one shared guard (app/core/security/ssrf.py) rather than four checks:
resolve the host, deny private/loopback/link-local/metadata, require https by
default, and revalidate on every redirect hop so a public host cannot 30x the
request onto an internal target. Credentials are stripped when a redirect leaves
the allow-list.

PT-12 lives here rather than with the telephony signature work because the
recording downloads are an egress concern: the fix is ssrf_safe_request around a
credentialed fetch, so it shares all its code with this guard and none with
callback authentication.

Two deliberate choices:

  • validate_egress_url runs BEFORE _build_auth_headers in the MCP path, so a
    hostile server URL never gets the tenant's credentials attached even briefly.
  • the webhook sender passes allow_http=True. PT-11 is about the resolved
    address, not the scheme, and this path never restricted the scheme before;
    https-only here would silently drop outcome webhooks for every tenant still on
    http. Plaintext delivery is logged instead.

_build_server_params becomes async because resolution is a DNS round-trip.

Tests: 11 reserved ranges, scheme and hostname cases, per-hop redirect
revalidation, credential stripping. 942 pass on this branch.


Independent by construction

This PR targets release directly. It shares no file with any other pentest PR, and all five were trial-merged pairwise — 10 of 10 pairs merge with no conflict, so they can land in any order.

The four config blocks that previously forced a chain (static.py, .env.example) now sit at four distinct anchors hundreds of lines apart, each next to the settings it belongs with, so independent branches auto-merge instead of colliding.

Merging all five reproduces the original #930 tree — static.py is identical content in a different order, .env.example differs only by one now-meaningless banner comment — and the combined suite runs 992 passed.

Evidence recording is in the comment below.

Copilot AI lite review requested due to automatic review settings August 8, 2026 19:46
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c62d1e4c-720d-4618-979e-38255fcffd91

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The change adds shared SSRF egress validation with DNS resolution, private-address blocking, host allow-lists, redirect checks, and credential filtering. HTTP, MCP, and webhook integrations now use this protection. A local-development override and focused tests were added.

Changes

SSRF egress protection

Layer / File(s) Summary
Egress policy and validation
app/core/security/ssrf.py, app/core/config/static.py, .env.example, tests/test_ssrf_egress.py
Adds configurable private-egress behavior, fail-closed DNS and IP checks, host allow-list matching, redirect validation, and credential filtering.
HTTP transport enforcement
app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py, app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py
Rejects LLM placeholders in URL authorities and uses shared SSRF validation for requests and redirects.
MCP server validation
app/ai/voice/agents/breeze_buddy/mcp/__init__.py, tests/test_mcp_approval.py
Validates resolved MCP URLs before credential construction and awaits asynchronous server parameter creation.
Webhook delivery enforcement
app/ai/voice/agents/breeze_buddy/utils/common.py
Validates webhook URLs before sending, warns for plaintext signed requests, and disables redirects for webhook POSTs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: swaroopvarma2359

Poem

A rabbit checks each route with care,
Blocks private paths from hidden lair.
DNS hops meet a watchful gate,
Safe redirects arrive in state.
“Hop secured!” the bunny sings.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the PR's main change: adding SSRF egress protection to server-side fetches.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pt-ssrf-egress

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🧹 Nitpick comments (2)
tests/test_ssrf_egress.py (1)

150-171: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for header-borne credential stripping.

ssrf_safe_request also removes non-safe headers on a credential-dropping hop through _without_credential_headers. The tests cover only auth. A regression that keeps Authorization in headers would pass the current suite. _FakeSession.request already receives **kwargs, so record and assert the headers per hop.

💚 Proposed test addition
     async def request(self, method, url, *, auth=None, allow_redirects=True, **kwargs):
-        self.requests.append({"method": method, "url": url, "auth": auth})
+        self.requests.append(
+            {
+                "method": method,
+                "url": url,
+                "auth": auth,
+                "headers": kwargs.get("headers"),
+            }
+        )
         return self._responses.pop(0)
async def test_ssrf_safe_request_strips_auth_headers_on_offsite_redirect():
    session = _FakeSession(
        [
            _FakeResp(302, {"Location": "https://1.1.1.1/next"}),
            _FakeResp(200, {}),
        ]
    )
    async with ssrf_safe_request(
        cast(aiohttp.ClientSession, session),
        "GET",
        "https://8.8.8.8/start",
        headers={"Authorization": "Bearer secret", "Accept": "application/json"},
        allowed_host_suffixes=["8.8.8.8"],
    ):
        pass
    assert "Authorization" in session.requests[0]["headers"]
    assert "Authorization" not in session.requests[1]["headers"]
    assert session.requests[1]["headers"]["Accept"] == "application/json"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_ssrf_egress.py` around lines 150 - 171, Add a separate redirect
test alongside test_ssrf_safe_request_strips_auth_on_offsite_redirect that
passes Authorization and a safe header through ssrf_safe_request, then records
per-hop headers via _FakeSession.request kwargs. Assert Authorization is present
on the allowed first hop, removed on the offsite redirect, and the safe Accept
header remains unchanged.
tests/test_mcp_approval.py (1)

37-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that MCP still calls the egress guard.

The autouse fixture removes SSRF validation for every test in this file. If a future change deletes the validate_egress_url call in _build_server_params, all tests here still pass. Record the validated URLs in the stub and add one test that asserts the guard ran before the server params were built.

💚 Proposed change
 `@pytest.fixture`(autouse=True)
 def _bypass_ssrf_egress(monkeypatch):
     """These tests use placeholder MCP hostnames to exercise approval-map /
     tool-loading logic. SSRF egress validation (tested separately in
     tests/test_ssrf_egress.py) would otherwise reject the unresolvable host.
     """
+    validated: list[str] = []
 
     async def _ok(url, *args, **kwargs):
+        validated.append(url)
         return ["203.0.113.10"]
 
     monkeypatch.setattr(_mcp_module, "validate_egress_url", _ok)
+    return validated

Then assert "https://shop.example/api/mcp" in _bypass_ssrf_egress in one loading test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_mcp_approval.py` around lines 37 - 47, Update the
_bypass_ssrf_egress fixture to record each URL passed to its _ok stub while
retaining the existing resolved-address return value. In a loading test that
builds server parameters, assert that "https://shop.example/api/mcp" was
recorded, proving _build_server_params still invokes validate_egress_url before
constructing the parameters.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py`:
- Around line 135-146: Update the exception handling in the requester method
around ssrf_safe_request to import and catch SSRFError explicitly, replacing the
now-unreachable aiohttp.TooManyRedirects branch. Return the same
security-rejection response without retrying, while leaving generic exception
retry behavior unchanged.

In `@app/ai/voice/agents/breeze_buddy/utils/common.py`:
- Around line 163-167: Update the signed webhook request in ssrf_safe_request to
pass max_redirects=0, ensuring the fixed tenant-configured endpoint does not
follow redirects or replay the POST payload to another host. Keep the existing
allow_http=True and per-hop SSRF protections unchanged.

In `@app/core/security/ssrf.py`:
- Around line 271-284: Update the redirect loop around the request method and
kwargs so 301, 302, and 303 responses rewrite the request to GET and remove body
arguments such as json and data before the next hop. Preserve the original
method and body only for 307 and 308 redirects, while retaining existing
cross-host credential handling and response release behavior.

---

Nitpick comments:
In `@tests/test_mcp_approval.py`:
- Around line 37-47: Update the _bypass_ssrf_egress fixture to record each URL
passed to its _ok stub while retaining the existing resolved-address return
value. In a loading test that builds server parameters, assert that
"https://shop.example/api/mcp" was recorded, proving _build_server_params still
invokes validate_egress_url before constructing the parameters.

In `@tests/test_ssrf_egress.py`:
- Around line 150-171: Add a separate redirect test alongside
test_ssrf_safe_request_strips_auth_on_offsite_redirect that passes Authorization
and a safe header through ssrf_safe_request, then records per-hop headers via
_FakeSession.request kwargs. Assert Authorization is present on the allowed
first hop, removed on the offsite redirect, and the safe Accept header remains
unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e5556a37-ffba-4837-880b-2ce3cf490a1b

📥 Commits

Reviewing files that changed from the base of the PR and between bff6425 and 074caba.

📒 Files selected for processing (9)
  • .env.example
  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py
  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py
  • app/ai/voice/agents/breeze_buddy/mcp/__init__.py
  • app/ai/voice/agents/breeze_buddy/utils/common.py
  • app/core/config/static.py
  • app/core/security/ssrf.py
  • tests/test_mcp_approval.py
  • tests/test_ssrf_egress.py

Comment on lines +135 to 146
# ssrf_safe_request re-validates every redirect hop so a
# public host can't 302 the request to an internal/metadata
# target (PT-07).
async with ssrf_safe_request(
self.session,
config.method.value,
url,
headers=headers,
json=resolved_body if resolved_body else None,
timeout=aiohttp.ClientTimeout(total=config.timeout),
max_redirects=HTTP_REQUEST_MAX_REDIRECTS,
allow_redirects=HTTP_REQUEST_MAX_REDIRECTS > 0,
) as response:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle SSRFError explicitly instead of retrying it.

ssrf_safe_request raises SSRFError when a redirect hop is blocked or the hop limit is reached. That exception is not an aiohttp.ClientError, so it falls into the generic except Exception at line 264. The executor then sleeps and retries up to config.max_retries times. Each retry re-sends the original request, including a POST body, before hitting the same blocked redirect. A security rejection must abort immediately.

The except aiohttp.TooManyRedirects branch at lines 249-259 is now unreachable, because redirects are followed manually with allow_redirects=False. Replace it with an SSRFError branch.

🔒️ Proposed fix
-                except aiohttp.TooManyRedirects:
-                    logger.error(
-                        f"HTTP {config.method.value} exceeded max redirects "
-                        f"({HTTP_REQUEST_MAX_REDIRECTS}), not retrying"
-                    )
-                    if fire_and_forget:
-                        return None
-                    return (
-                        0,
-                        f"Too many redirects (max: {HTTP_REQUEST_MAX_REDIRECTS})",
-                    )
+                except SSRFError as e:
+                    logger.error(
+                        f"HTTP {config.method.value} blocked by SSRF egress guard, "
+                        f"not retrying: {e}"
+                    )
+                    if fire_and_forget:
+                        return None
+                    return (0, f"Blocked by egress policy: {e}")

Add SSRFError to the import on line 45:

-from app.core.security.ssrf import ssrf_safe_request, validate_egress_url
+from app.core.security.ssrf import SSRFError, ssrf_safe_request, validate_egress_url
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# ssrf_safe_request re-validates every redirect hop so a
# public host can't 302 the request to an internal/metadata
# target (PT-07).
async with ssrf_safe_request(
self.session,
config.method.value,
url,
headers=headers,
json=resolved_body if resolved_body else None,
timeout=aiohttp.ClientTimeout(total=config.timeout),
max_redirects=HTTP_REQUEST_MAX_REDIRECTS,
allow_redirects=HTTP_REQUEST_MAX_REDIRECTS > 0,
) as response:
from app.core.security.ssrf import SSRFError, ssrf_safe_request, validate_egress_url
...
except SSRFError as e:
logger.error(
f"HTTP {config.method.value} blocked by SSRF egress guard, "
f"not retrying: {e}"
)
if fire_and_forget:
return None
return (0, f"Blocked by egress policy: {e}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py` around
lines 135 - 146, Update the exception handling in the requester method around
ssrf_safe_request to import and catch SSRFError explicitly, replacing the
now-unreachable aiohttp.TooManyRedirects branch. Return the same
security-rejection response without retrying, while leaving generic exception
retry behavior unchanged.

Comment on lines +163 to +167
# allow_redirects=False + per-hop revalidation so a public host can't
# 30x-redirect the signed payload to an internal target.
async with ssrf_safe_request(
session, "POST", url, json=data, headers=headers, allow_http=True
) as response:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Set max_redirects=0 for the signed webhook.

The comment states allow_redirects=False, but that applies only to the underlying aiohttp call. ssrf_safe_request defaults to max_redirects=3 and follows redirects itself. Each hop re-sends the POST body, so the lead payload can be delivered to a redirect target that the tenant did not configure. The checksum header is dropped on a cross-host hop, but the payload is not. This is the redirect-replay behavior flagged in app/core/security/ssrf.py.

A webhook destination is a fixed, tenant-configured endpoint. It does not need redirect following.

🔒️ Proposed fix
-            # allow_redirects=False + per-hop revalidation so a public host can't
-            # 30x-redirect the signed payload to an internal target.
+            # max_redirects=0: the signed payload must reach the configured
+            # endpoint only. A 30x response is treated as a failed attempt.
             async with ssrf_safe_request(
-                session, "POST", url, json=data, headers=headers, allow_http=True
+                session,
+                "POST",
+                url,
+                json=data,
+                headers=headers,
+                allow_http=True,
+                max_redirects=0,
             ) as response:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# allow_redirects=False + per-hop revalidation so a public host can't
# 30x-redirect the signed payload to an internal target.
async with ssrf_safe_request(
session, "POST", url, json=data, headers=headers, allow_http=True
) as response:
# max_redirects=0: the signed payload must reach the configured
# endpoint only. A 30x response is treated as a failed attempt.
async with ssrf_safe_request(
session,
"POST",
url,
json=data,
headers=headers,
allow_http=True,
max_redirects=0,
) as response:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/utils/common.py` around lines 163 - 167,
Update the signed webhook request in ssrf_safe_request to pass max_redirects=0,
ensuring the fixed tenant-configured endpoint does not follow redirects or
replay the POST payload to another host. Keep the existing allow_http=True and
per-hop SSRF protections unchanged.

Comment thread app/core/security/ssrf.py
Comment on lines +271 to +284
response = await session.request(
method, current, auth=send_auth, allow_redirects=False, **send_kwargs
)
location = response.headers.get("Location")
if response.status in _REDIRECT_STATUSES and location and hop < max_redirects:
response.release()
current = urljoin(current, location)
continue

try:
yield response
finally:
response.release()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not replay the request body and method on 301/302/303 redirects.

The loop repeats method and all kwargs (including json= / data=) on every hop. For 303, and by universal client convention for 301 and 302, a POST must be rewritten to a GET without a body. aiohttp did this before this change, so the behavior of HttpRequestExecutor and send_webhook_with_retry changes: a redirected POST is now re-sent as a POST with the same payload.

Two consequences:

  • A non-idempotent write can be delivered twice, once to the original host and once to the redirect target.
  • Header credentials are dropped on a cross-host hop, but the request body still travels to the new host. For send_webhook_with_retry that body is the lead payload.

Rewrite the method and strip the body on 301, 302, and 303; keep both only for 307 and 308.

🔒️ Proposed fix for redirect method handling
     current = url
+    send_method = method
     prev_host: Optional[str] = None
     for hop in range(max_redirects + 1):
         response = await session.request(
-            method, current, auth=send_auth, allow_redirects=False, **send_kwargs
+            send_method, current, auth=send_auth, allow_redirects=False, **send_kwargs
         )
         location = response.headers.get("Location")
         if response.status in _REDIRECT_STATUSES and location and hop < max_redirects:
             response.release()
             current = urljoin(current, location)
+            if response.status in (301, 302, 303) and send_method.upper() not in (
+                "GET",
+                "HEAD",
+            ):
+                send_method = "GET"
+                kwargs = {
+                    k: v for k, v in kwargs.items() if k not in ("json", "data")
+                }
             continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/security/ssrf.py` around lines 271 - 284, Update the redirect loop
around the request method and kwargs so 301, 302, and 303 responses rewrite the
request to GET and remove body arguments such as json and data before the next
hop. Preserve the original method and body only for 307 and 308 redirects, while
retaining existing cross-host credential handling and response release behavior.

Copilot AI 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.

Pull request overview

Introduces a shared SSRF egress-guard to validate all server-side, user/template/LLM-influenced outbound HTTP destinations (including per-hop redirect revalidation), and wires it into MCP server discovery, the HTTP global-function executor, and reporting webhooks, with accompanying tests and a local-dev escape hatch.

Changes:

  • Added app/core/security/ssrf.py with validate_egress_url() and an aiohttp helper ssrf_safe_request() that revalidates redirects and strips credentials on off-allowlist/cross-host hops.
  • Applied SSRF validation to MCP server URL construction, HTTP request execution, and webhook sending (including redirect-safe behavior).
  • Added SSRF-focused tests and updated existing MCP-approval tests to bypass SSRF for placeholder hostnames.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_ssrf_egress.py Adds unit tests for IP/range blocking, DNS resolution checks, and redirect-hop revalidation/credential stripping.
tests/test_mcp_approval.py Adds an autouse fixture to bypass SSRF validation for placeholder MCP hostnames used in these tests.
app/core/security/ssrf.py New shared SSRF egress validator + redirect-safe aiohttp request helper.
app/core/config/static.py Adds SSRF_ALLOW_PRIVATE_EGRESS local-dev escape hatch.
app/ai/voice/agents/breeze_buddy/utils/common.py Validates reporting webhooks with SSRF guard and uses redirect-safe request helper.
app/ai/voice/agents/breeze_buddy/mcp/init.py Validates resolved MCP server URLs before building auth headers; makes _build_server_params async.
app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py Switches HTTP execution to shared SSRF validation + per-hop redirect revalidation.
app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py Blocks LLM-sourced placeholders from occupying URL authority/host position.
.env.example Documents SSRF_ALLOW_PRIVATE_EGRESS configuration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/test_ssrf_egress.py
validate_egress_url,
)


Comment thread app/core/security/ssrf.py Outdated
Comment on lines +22 to +24
- This module deliberately has no dependency on aiohttp/httpx so it can guard
any client. Callers pair it with ``allow_redirects=False`` (or manual per-hop
re-validation) and, where credentials are attached, a host allow-list.
Comment thread app/core/security/ssrf.py
Comment on lines +240 to +242
current = url
prev_host: Optional[str] = None
for hop in range(max_redirects + 1):

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Files reviewed: 9 changed files (focused on the 5 source files with security-bearing changes).

New issues raised this run

Severity Count Files
🔒 CRITICAL 4 http_requester.py, mcp/__init__.py, utils/common.py, ssrf.py
⚠️ MAJOR 2 ssrf.py (redirect method/body handling, max-redirects off-by-one)
💡 MINOR 1 ssrf.py (docstring accuracy)

Why REQUEST_CHANGES

Four new CRITICAL security issues meet the blocking criteria:

  1. http_requester.py: SSRFError is not caught explicitly, so a blocked egress URL or redirect is retried up to max_retries times, replaying the request body each time. Security rejections must abort immediately.
  2. mcp/__init__.py: The direct-HTTP MCP tool handler (_create_direct_http_tool_handler) bypasses ssrf_safe_request entirely and uses httpx.AsyncClient.post(...) with implicit redirect following. Tenant credentials attached to the request can be 302'd to internal/metadata targets without revalidation or credential stripping.
  3. utils/common.py: send_webhook_with_retry uses ssrf_safe_request with the default max_redirects=3, replaying the signed lead payload on every redirect hop. Webhook endpoints are fixed, tenant-configured destinations and should not follow redirects.
  4. ssrf.py: ssrf_safe_request passes allow_redirects=False directly to session.request while also accepting arbitrary **kwargs. A caller-supplied allow_redirects kwarg causes TypeError: got multiple values for keyword argument 'allow_redirects'.

Additional MAJOR concerns

  • Redirect method/body semantics are incorrect: 301/302/303 should rewrite non-GET/HEAD to GET and drop the body; only 307/308 should preserve both. The current code re-POSTs the payload to redirect targets.
  • The redirect loop yields the final 3xx response instead of raising "too many redirects" when the hop budget is exhausted, changing the max_redirects contract.

Next steps

Please address the four CRITICAL items and the two MAJOR redirect-handling issues, then re-request review. The existing automated comments from CodeRabbit/Copilot overlap with some of these points; the fixes above should resolve those threads as well.

@murdore

murdore commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Evidence

A real screencast — Chrome DevTools Page.startScreencast capturing every repaint while the page is driven by real Input.dispatchKeyEvent/dispatchMouseEvent calls. Frames carry their arrival timestamps and are encoded with those durations, so the pacing you see is the pacing that happened.

Each take runs three acts: attack release (if that were already refused, the PR would be guarding something that was never broken), the same attack against this PR, then the legitimate paths re-run — because a control that bounds an attack by breaking normal use is not a fix.

SSRF egress guard

pr987-ssrf-egress.mp4

local original: /Users/sachinsharma/Developer/temp/clairvoyance/.proof-video/pr987/pr987-ssrf-egress.mp4

A real internal service listens on loopback and returns a secret. On release the webhook path fetches it:

app/core/security/ssrf.py                    ABSENT   no egress validation exists
egress to cloud metadata                     ALLOWED
egress to loopback service                   ALLOWED   read back: INTERNAL-ONLY-DB-CREDENTIAL-8f21
egress to private 10.x                       ALLOWED
egress to file scheme                        ALLOWED   (no scheme restriction)

On this branch every one is refused before a packet leaves, and by the address rule rather than the scheme rule — the probe passes allow_http=True deliberately, otherwise the scheme check fires first and overstates what this proves:

egress to cloud metadata     REFUSED   Blocked egress to link-local address 169.254.169.254
egress to loopback service   REFUSED   Blocked egress to loopback address 127.0.0.1
egress to private 10.x       REFUSED   Blocked egress to private address 10.0.0.7
egress to file scheme        REFUSED   Disallowed URL scheme 'file'

And what must still work:

ordinary https egress  api.github.com                ALLOWED  (unchanged)
plaintext http webhook (allow_http=True)             ALLOWED  (logged, not dropped)

Recorded against 2eb0927, i.e. after Tara-ag's review — so it includes the call-time egress check in the direct-HTTP MCP handler and the redirect fixes, not just the guard. PT-12 (recording host pinning) is covered by the suite rather than by this take. 948 tests pass.

Re-recorded 2026-08-09. The first set of takes had two defects worth naming. They compared each PR against its old parent branch, which was accurate when these were a stack but describes a topology that no longer exists — every PR is independent off release now, and the baseline in these recordings is release itself. And the "dashboard" pane sat on a login form for the whole runtime; it now signs in for real and the probes run beside a live console. The recorder also gained an assertion that aborts a take if that sign-in does not land, because a silently-failed login is exactly the kind of thing that gets captioned as a success.

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Files reviewed: 12 changed files (walked the key security-relevant ones).

New issues raised this run

Severity Count Files
🔒 CRITICAL 1 utils/common.py (webhook redirect replay)
⚠️ MAJOR 2 http_requester.py (SSRFError retry), ssrf.py (redirect method/body handling)
💡 MINOR 4 ssrf.py docstring, ssrf.py kwargs collision, tests/test_ssrf_egress.py env determinism, http_handler.py subdomain allowance
💬 SUGGESTION 4 mcp/__init__.py direct-HTTP revalidation, static.py dynamic config, .env.example restart note, tests/test_mcp_approval.py regression coverage

Blocking rationale

I am requesting changes because of one new CRITICAL issue and two new MAJOR issues that meet the blocking criteria for SSRF/credential safety:

  1. CRITICAL — Signed webhook follows redirects (utils/common.py): send_webhook_with_retry uses ssrf_safe_request with the default max_redirects=3. A tenant-configured webhook endpoint does not need redirects, and each redirect hop replays the POST body (lead payload) to a host the tenant did not configure. The checksum header is dropped on cross-host hops, but the payload is not. This is a concrete SSRF/PII-exfiltration primitive. Pass max_redirects=0.

  2. MAJOR — SSRFError is retried (http_requester.py): ssrf_safe_request raises SSRFError on a blocked redirect hop or hop-limit exceeded. That exception is not an aiohttp.ClientError, so it falls into the generic except Exception and triggers the full retry loop, resending the request body each time. A security rejection must abort immediately. Add an explicit except SSRFError branch and remove the now-unreachable except aiohttp.TooManyRedirects branch.

  3. MAJOR — Redirect method/body handling is incorrect (ssrf.py): The manual redirect loop repeats the original method and body kwargs on every hop. For 301/302/303, non-GET/HEAD methods should be rewritten to GET and the body dropped (aiohttp's previous behavior); only 307/308 should preserve method/body. Without this, a POST can be replayed to an attacker-controlled redirect target.

Existing comments acknowledged

I did not duplicate points already raised by CodeRabbit or Copilot. The CRITICAL webhook-redirect issue overlaps with a Copilot comment that asked for max_redirects=0; I am elevating it to CRITICAL because the payload-replay consequence is severe and the existing comment is unresolved. The other new issues are distinct.

Next steps

  • Address the three blocking issues above.
  • Consider the MINOR/SUGGESTION items at the author's discretion.
  • Re-request review once fixes are pushed.

from app.core.logger import logger


def _authority_region(url_template: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: _authority_region strips the scheme by splitting on ://, but a URL template with an empty scheme such as //{host}/path will leave //{host} in candidate and then split on / to {host}, which is correct. However, a malicious template like https://{host}.evil.com/path will return {host}.evil.com and the subsequent LLM-field check only looks for exact {field_name} or {arg_name} substrings. That means an LLM-sourced field used as a subdomain prefix (e.g. https://{shop}.example.com/...) is allowed, while the pentest concern is about the host position being fully attacker-controlled. Consider documenting this intentional subdomain allowance explicitly, or tightening the check if subdomain takeover via LLM values is in scope for PT-17.

url=url,
# ssrf_safe_request re-validates every redirect hop so a
# public host can't 302 the request to an internal/metadata
# target (PT-07).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ MAJOR: Switching to ssrf_safe_request is correct for per-hop SSRF validation, but the surrounding exception handling is now inconsistent. ssrf_safe_request passes allow_redirects=False and follows redirects manually, so the except aiohttp.TooManyRedirects branch further down is unreachable. Worse, SSRFError is not an aiohttp.ClientError, so a blocked redirect hop falls into the generic except Exception and triggers the full retry loop, resending the request body each time.

Import SSRFError alongside the other helpers and add an explicit handler that aborts immediately:

from app.core.security.ssrf import SSRFError, ssrf_safe_request, validate_egress_url
                except SSRFError as e:
                    logger.error(
                        f"HTTP {config.method.value} blocked by SSRF egress guard, "
                        f"not retrying: {e}"
                    )
                    if fire_and_forget:
                        return None
                    return (0, f"Blocked by egress policy: {e}")

Then remove the now-dead except aiohttp.TooManyRedirects block.

Comment thread tests/test_ssrf_egress.py
validate_egress_url,
)


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 MINOR: test_ip_block_reason_flags_metadata_and_private asserts that private/loopback addresses are blocked, but the module-level _ALLOW_PRIVATE_EGRESS is initialized from SSRF_ALLOW_PRIVATE_EGRESS at import time. If a developer has SSRF_ALLOW_PRIVATE_EGRESS=true in their environment, these assertions will fail. Force the flag off via an autouse fixture so the tests are deterministic regardless of environment.

@pytest.fixture(autouse=True)
def _force_private_egress_disabled(monkeypatch):
    monkeypatch.setattr(ssrf, "_ALLOW_PRIVATE_EGRESS", False)

Comment thread app/core/config/static.py
CREDENTIAL_ENCRYPTION_KEY = os.getenv("CREDENTIAL_ENCRYPTION_KEY", "")

# JWT Authentication Configuration
# Local-dev escape hatch for the SSRF egress guard (app/core/security/ssrf.py):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: SSRF_ALLOW_PRIVATE_EGRESS is a welcome addition and defaults securely. Consider adding it to the runtime dynamic config layer (app/core/config/dynamic.py) as well, so operators can toggle it without a pod restart in staging/debug scenarios. Not blocking.

Comment thread .env.example

BREEZE_MCP_ENDPOINT_PATH="/ai/neurolink"
TWILIO_ACCOUNT_SID=""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: Good documentation of the local-dev escape hatch. Consider adding a note that SSRF_ALLOW_PRIVATE_EGRESS is read once at import and requires a process restart to take effect, so developers don't expect runtime toggling via env reload.

)


@pytest.fixture(autouse=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: The _bypass_ssrf_egress fixture is pragmatic for unit tests that aren't testing egress validation. Consider adding a dedicated test that exercises the real validate_egress_url rejection path for an MCP server URL (e.g. https://127.0.0.1/...) so the integration between _build_server_params and the SSRF guard has regression coverage, since the bypass fixture currently masks that behavior across this file.

@murdore
murdore force-pushed the fix/pt-ssrf-egress branch from 9694d9a to 2eb0927 Compare August 9, 2026 06:13
@murdore

murdore commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

All six addressed — thanks, these were right

Every one of the four CRITICAL and two MAJOR items is fixed in 2eb0927. Notes where the fix differs from the suggestion, or where I think the finding understated the problem.

CRITICAL 1 — SSRFError retried (http_requester.py)

Fixed. Explicit except SSRFError that aborts and returns (0, "Request blocked by egress policy: …"). That branch also replaces the except aiohttp.TooManyRedirects one, which was already dead: ssrf_safe_request disables aiohttp's redirect handling and follows hops itself, so aiohttp can never raise it here.

CRITICAL 2 — direct-HTTP MCP handler bypasses the guard (mcp/__init__.py)

Fixed, and this was the one that mattered most — my own evidence video claims "every egress path" while this path had none.

One correction to the finding: httpx does not follow redirects by default, so implicit redirect-following wasn't the live hole. The real gap is timing — the URL was validated once at flow-build time, but this handler runs per tool call, minutes later and repeatedly, with tenant credentials from server_params.headers attached. A name that resolved public at build and internal at call is exactly the rebinding case the module docstring claims to narrow.

So it now calls validate_egress_url at call time, before the credential headers are assembled — a blocked host never has credentials built for it at all — and follow_redirects=False is stated explicitly so a future default change can't reopen it.

CRITICAL 3 — signed webhook follows redirects (utils/common.py)

Fixed with max_redirects=0, as suggested. Agreed on the severity: per-hop revalidation would still block an internal target, but the payload has already left for any public one, and the checksum header is dropped on cross-host hops while the body is not. SSRFError also aborts the retry loop instead of re-sending at the same blocked target.

CRITICAL 4 — allow_redirects kwargs collision (ssrf.py)

Fixed by kwargs.pop("allow_redirects", None) with a warning. Dropping it is the right call rather than honouring it: this helper cannot function if redirects are handed back to aiohttp.

MAJOR 1 — redirect method/body semantics

Fixed. 301/302/303 rewrite a non-GET/HEAD method to GET and strip json/data/content; only 307/308 preserve both.

MAJOR 2 — hop budget returns the final 3xx

Fixed. The check is now if hop >= max_redirects: raise inside the redirect branch. The raise at the end of the loop was unreachable — the old condition hop < max_redirects fell through to yield response, so a caller at the limit received a 3xx as though it were the answer.

Minor / suggestions

  • Docstring — corrected. The module does depend on aiohttp for ssrf_safe_request; only the validation half is client-agnostic, and the docstring now says which functions are which.
  • Test env determinism — fixed with an autouse fixture pinning _ALLOW_PRIVATE_EGRESS to False. Good catch: a developer with the escape hatch on locally would have seen every assertion in the file silently invert.
  • test_mcp_approval real rejection path — added, though in test_ssrf_egress.py where the other egress cases live: test_direct_http_mcp_handler_revalidates_at_call_time monkeypatches resolution to return 169.254.169.254 and asserts the handler refuses and that no httpx client is ever constructed.
  • SSRF_ALLOW_PRIVATE_EGRESS in dynamic config — not taken. A runtime-togglable switch that disables SSRF protection is a worse failure mode than a pod restart; leaving it import-time means it cannot be flipped by anything short of a deploy.
  • .env.example restart note — not added for the same reason it's now moot: the value is deliberately import-time only.
  • http_handler.py subdomain allowance — reviewed, no change. _authority_region extracts the authority for the LLM-host check; the allow-list matching in host_matches_allowlist already enforces a dot boundary, which test_host_allowlist_suffix_confusion_is_rejected covers.

948 tests pass on this branch, up from 942 — six new cases, one per fix. black / isort / autoflake clean, pyrefly 0 errors.

Re-requesting review.

@murdore
murdore requested a review from Tara-ag August 9, 2026 06:14

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed 13 changed files. No new blocking issues found beyond the points already raised by CodeRabbit, Copilot, and Tara-ag.

Summary of existing threads (already handled; not duplicated):

  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py: SSRFError handling and removal of the now-unreachable aiohttp.TooManyRedirects branch.
  • app/ai/voice/agents/breeze_buddy/utils/common.py: max_redirects=0 for the signed reporting webhook.
  • app/core/security/ssrf.py: redirect method/body semantics (301/302/303 → GET without body; 307/308 preserve), and guarding against caller-supplied allow_redirects collisions.
  • tests/test_ssrf_egress.py: deterministic _ALLOW_PRIVATE_EGRESS pinning via autouse fixture.
  • .env.example / app/core/config/static.py: documentation of the import-time SSRF_ALLOW_PRIVATE_EGRESS escape hatch.
  • tests/test_mcp_approval.py: suggestion for a dedicated MCP + real validate_egress_url rejection test.

New comment added this review:

  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py:44 — SUGGESTION to document or tighten the intentional allowance of LLM-sourced placeholders used as subdomain prefixes in the URL host position (PT-17).

No hardcoded secrets, no SQL injection, no auth bypass, no migration edits, and no unresolved cross-tenant concerns were introduced. The SSRF guard is centralized, defaults securely, and is backed by focused tests. Approving.

from app.core.logger import logger


def _authority_region(url_template: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: _authority_region strips the scheme by splitting on ://, but a URL template with an empty scheme such as //{host}/path will leave //{host} in candidate and then split on / to {host}, which is correct. However, a malicious template like https://{host}.evil.com/path will return {host}.evil.com and the subsequent LLM-field check only looks for exact {field_name} or {arg_name} substrings. That means an LLM-sourced field used as a subdomain prefix (e.g. https://{shop}.example.com/...) is allowed, while the pentest concern is about the host position being fully attacker-controlled. Consider documenting this intentional subdomain allowance explicitly, or tightening the check if subdomain takeover via LLM values is in scope for PT-17.

@murdore
murdore force-pushed the fix/pt-ssrf-egress branch from 2eb0927 to 226c458 Compare August 12, 2026 07:12

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Files reviewed: 13 changed files (walked the key security-relevant ones).

New issues raised this run

Severity Count Files
🔒 CRITICAL 0
⚠️ MAJOR 1 http_requester.py (SSRFError handler fragility)
💡 MINOR 1 http_handler.py (missing URL guard)
💬 SUGGESTION 5 http_handler.py, mcp/__init__.py, exotel/recording.py, static.py, .env.example

Blocking rationale

No new blocking issues were found this run. The four CRITICAL items from my earlier reviews have been addressed in the current HEAD:

  1. http_requester.py: explicit except SSRFError branch added; security rejections abort immediately instead of retrying.
  2. mcp/__init__.py: direct-HTTP MCP handler re-validates egress at call time before assembling tenant credentials.
  3. utils/common.py: signed reporting webhook now passes max_redirects=0, refusing redirects outright.
  4. ssrf.py: caller-supplied allow_redirects is popped/ignored; 301/302/303 rewrite to GET and drop body; 307/308 preserve method/body; exhausted hop budget raises.

Existing comments acknowledged

I did not duplicate points already raised by CodeRabbit, Copilot, or my earlier reviews. The remaining threads are either suggestions/minors or the one new MAJOR item below.

New issues this run

  • MAJOR — http_requester.py:246: the SSRFError handler is present and ordered correctly before the generic except Exception, but because SSRFError subclasses ValueError, any future refactor that places a broad except Exception (or except ValueError) earlier in the chain would silently swallow security rejections and retry the blocked request. Add a regression test or a maintainer comment warning against reordering.
  • MINOR — http_handler.py:125: if config.http_request.url is None, _authority_region receives "" and the LLM-host check silently skips. Raise a clear template error instead of falling through to FieldResolver.
  • SUGGESTION — http_handler.py:44: document or tighten the intentional allowance of LLM-sourced placeholders used as subdomain prefixes in the URL host position (PT-17).
  • SUGGESTION — mcp/__init__.py:253: consider applying the same call-time egress guard to the discovery-path _create_mcp_tool_handler (pooled and unpooled), since MCPClient also posts to server_params.url with tenant credentials.
  • SUGGESTION — exotel/recording.py:44: document how the Exotel allow-list suffix was chosen, in case regional/CDN domains emerge.
  • SUGGESTION — static.py:339 / .env.example:142: consider noting that SSRF_ALLOW_PRIVATE_EGRESS is read once at import and requires a process restart.

Decision

Approving. No hardcoded secrets, no SQL injection, no auth bypass, no migration edits, and no unresolved cross-tenant concerns were introduced. The SSRF guard is centralized, defaults securely, and is backed by focused tests. Please consider the MAJOR and MINOR items before merge.

Comment thread .env.example

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: Consider adding a note that SSRF_ALLOW_PRIVATE_EGRESS is read once at import time and requires a process restart to take effect, so developers don't expect runtime toggling via env reload.

from app.core.logger import logger


def _authority_region(url_template: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: _authority_region strips the scheme by splitting on ://, but a URL template with an empty scheme such as //{host}/path will leave //{host} in candidate and then split on / to {host}, which is correct. However, a malicious template like https://{host}.evil.com/path will return {host}.evil.com and the subsequent LLM-field check only looks for exact {field_name} or {arg_name} substrings. That means an LLM-sourced field used as a subdomain prefix (e.g. https://{shop}.example.com/...) is allowed, while the pentest concern is about the host position being fully attacker-controlled. Consider documenting this intentional subdomain allowance explicitly, or tightening the check if subdomain takeover via LLM values is in scope for PT-17.

@@ -104,6 +125,30 @@ async def http_function_handler(
"error": f"Missing required arguments: {', '.join(missing_args)}",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 MINOR: The LLM-host check runs after missing_args is computed but before FieldResolver substitutes values. This is the right ordering for the host-position guard. However, if config.http_request.url is absent (None), _authority_region receives "" and the loop silently skips it. Consider raising a clear template error when the URL itself is missing, so a malformed global function fails early rather than falling through to FieldResolver.

@@ -243,17 +246,22 @@ async def execute(
logger.warning(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ MAJOR: The SSRFError handler is now present, which is good. However, verify that the generic except Exception branch at the end of this retry loop does not accidentally catch SSRFError on future refactor. Because SSRFError subclasses ValueError, any broad except Exception placed earlier in the chain would swallow it. Currently it is placed before the generic handler, so it works, but add a regression test or a code comment warning maintainers not to reorder these handlers.

@@ -252,6 +253,24 @@ def _create_direct_http_tool_handler(
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: The call-time re-validation in the direct-HTTP MCP handler is good for DNS rebinding. Consider also applying the same guard to the discovery-path _create_mcp_tool_handler (both pooled and unpooled branches), because MCPClient also posts to server_params.url with tenant credentials attached. While follow_redirects defaults to False in httpx, the initial egress still resolves the host and could hit a rebound internal address.

from app.core.logger import logger


def _authority_region(url_template: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: _authority_region strips the scheme by splitting on ://, but a URL template with an empty scheme such as //{host}/path will leave //{host} in candidate and then split on / to {host}, which is correct. However, a malicious template like https://{host}.evil.com/path will return {host}.evil.com and the subsequent LLM-field check only looks for exact {field_name} or {arg_name} substrings. That means an LLM-sourced field used as a subdomain prefix (e.g. https://{shop}.example.com/...) is allowed, while the pentest concern is about the host position being fully attacker-controlled. Consider documenting this intentional subdomain allowance explicitly, or tightening the check if subdomain takeover via LLM values is in scope for PT-17.

@@ -104,6 +125,30 @@ async def http_function_handler(
"error": f"Missing required arguments: {', '.join(missing_args)}",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 MINOR: The LLM-host check runs after missing_args is computed but before FieldResolver substitutes values. This is the right ordering for the host-position guard. However, if config.http_request.url is absent (None), _authority_region receives "" and the loop silently skips it. Consider raising a clear template error when the URL itself is missing, so a malformed global function fails early rather than falling through to FieldResolver.

@@ -243,17 +246,22 @@ async def execute(
logger.warning(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ MAJOR: The SSRFError handler is now present, which is good. However, verify that the generic except Exception branch at the end of this retry loop does not accidentally catch SSRFError on future refactor. Because SSRFError subclasses ValueError, any broad except Exception placed earlier in the chain would swallow it. Currently it is placed before the generic handler, so it works, but add a regression test or a code comment warning maintainers not to reorder these handlers.

@@ -252,6 +253,24 @@ def _create_direct_http_tool_handler(
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: The call-time re-validation in the direct-HTTP MCP handler is good for DNS rebinding. Consider also applying the same guard to the discovery-path _create_mcp_tool_handler (both pooled and unpooled branches), because MCPClient also posts to server_params.url with tenant credentials attached. While follow_redirects defaults to False in httpx, the initial egress still resolves the host and could hit a rebound internal address.

@@ -34,9 +44,16 @@ async def download_call_recording(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: _EXOTEL_HOST_SUFFIXES = ("exotel.com",) is a good allow-list gate. Consider whether Exotel recording URLs can ever be served from a regional subdomain or CDN not ending in exotel.com (e.g. exotel.io, AWS CloudFront, or a partner domain). If so, the allow-list may need expansion; if not, document how the suffix was chosen so future maintainers don't broaden it without verification.

@murdore
murdore force-pushed the fix/pt-ssrf-egress branch from 226c458 to 76a6a4f Compare August 14, 2026 03:22

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Files reviewed: 14 changed files (walked the security-relevant source files and tests).

New issues raised this run

Severity Count Files
🔒 CRITICAL 0
⚠️ MAJOR 1 http_requester.py
💡 MINOR 1 http_handler.py
💬 SUGGESTION 5 .env.example, http_handler.py, mcp/__init__.py, exotel/recording.py, managers/pre_checks.py

Blocking rationale

No new blocking issues were found this run. The four CRITICAL items from earlier reviews have been addressed in the current HEAD:

  1. http_requester.py: explicit except SSRFError branch added; security rejections abort immediately instead of retrying.
  2. mcp/__init__.py: direct-HTTP MCP handler re-validates egress at call time before assembling tenant credentials.
  3. utils/common.py: signed reporting webhook now passes max_redirects=0, refusing redirects outright.
  4. ssrf.py: caller-supplied allow_redirects is popped/ignored; 301/302/303 rewrite to GET and drop body; 307/308 preserve method/body; exhausted hop budget raises.

Existing comments acknowledged

I did not duplicate points already raised by CodeRabbit, Copilot, or earlier reviews. The remaining threads are either suggestions/minors or the one new MAJOR item below.

New issues this run

  • MAJOR — http_requester.py:246: the SSRFError handler is present and ordered correctly before the generic except Exception, but because SSRFError subclasses ValueError, any future refactor that places a broad except Exception (or except ValueError) earlier in the chain would silently swallow security rejections and retry the blocked request. Add a regression test or a maintainer comment warning against reordering.
  • MINOR — http_handler.py:125: if config.http_request.url is None, _authority_region receives "" and the LLM-host check silently skips. Raise a clear template error instead of falling through to FieldResolver.
  • SUGGESTION — http_handler.py:44: document or tighten the intentional allowance of LLM-sourced placeholders used as subdomain prefixes in the URL host position (PT-17).
  • SUGGESTION — mcp/__init__.py:253: consider applying the same call-time egress guard to the discovery-path _create_mcp_tool_handler (pooled and unpooled), since MCPClient also posts to server_params.url with tenant credentials.
  • SUGGESTION — exotel/recording.py:44: document how the Exotel allow-list suffix was chosen, in case regional/CDN domains emerge.
  • SUGGESTION — .env.example:142: note that SSRF_ALLOW_PRIVATE_EGRESS is read once at import and requires a process restart.
  • SUGGESTION — managers/pre_checks.py:127: add a regression test asserting that validate_egress_url runs before _build_auth_headers in _build_server_params.

Decision

Approving. No hardcoded secrets, no SQL injection, no auth bypass, no migration edits, and no unresolved cross-tenant concerns were introduced. The SSRF guard is centralized, defaults securely, and is backed by focused tests. Please consider the MAJOR and MINOR items before merge.

Comment thread .env.example
# enable it (per-deployment) when template authorship is fully trusted.
ENABLE_CUSTOM_PYTHON_FUNCTIONS=false
TWILIO_ACCOUNT_SID=""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: The comment correctly frames SSRF_ALLOW_PRIVATE_EGRESS as local-dev only, but it doesn't mention that the value is read once at import time and requires a process restart to take effect. Adding that note will prevent operators from expecting runtime toggling via env reload.

from app.core.logger import logger


def _authority_region(url_template: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: _authority_region strips the scheme by splitting on ://, but a URL template with an empty scheme such as //{host}/path will leave //{host} in candidate and then split on / to {host}, which is correct. However, a malicious template like https://{host}.evil.com/path will return {host}.evil.com and the subsequent LLM-field check only looks for exact {field_name} or {arg_name} substrings. That means an LLM-sourced field used as a subdomain prefix (e.g. https://{shop}.example.com/...) is allowed, while the pentest concern is about the host position being fully attacker-controlled. Consider documenting this intentional subdomain allowance explicitly, or tightening the check if subdomain takeover via LLM values is in scope for PT-17.

@@ -104,6 +125,30 @@ async def http_function_handler(
"error": f"Missing required arguments: {', '.join(missing_args)}",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 MINOR: The LLM-host check runs after missing_args is computed but before FieldResolver substitutes values. This is the right ordering for the host-position guard. However, if config.http_request.url is absent (None), _authority_region receives "" and the loop silently skips it. Consider raising a clear template error when the URL itself is missing, so a malformed global function fails early rather than falling through to FieldResolver.

@@ -243,17 +246,22 @@ async def execute(
logger.warning(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ MAJOR: The SSRFError handler is now present, which is good. However, verify that the generic except Exception branch at the end of this retry loop does not accidentally catch SSRFError on future refactor. Because SSRFError subclasses ValueError, any broad except Exception placed earlier in the chain would swallow it. Currently it is placed before the generic handler, so it works, but add a regression test or a code comment warning maintainers not to reorder these handlers.

@@ -252,6 +253,24 @@ def _create_direct_http_tool_handler(
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: The call-time re-validation in the direct-HTTP MCP handler is good for DNS rebinding. Consider also applying the same guard to the discovery-path _create_mcp_tool_handler (both pooled and unpooled branches), because MCPClient also posts to server_params.url with tenant credentials attached. While follow_redirects defaults to False in httpx, the initial egress still resolves the host and could hit a rebound internal address.

@@ -34,9 +44,16 @@ async def download_call_recording(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: _EXOTEL_HOST_SUFFIXES now includes the configured subdomain host plus exotel.com, which is a good allow-list gate. Consider whether Exotel recording URLs can ever be served from a regional subdomain or CDN not ending in exotel.com (e.g. exotel.io, AWS CloudFront, or a partner domain). If so, the allow-list may need expansion; if not, document how the suffix was chosen so future maintainers don't broaden it without verification.

@@ -127,9 +127,14 @@ async def _fetch_mcp_response(
if not isinstance(arguments, dict):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: The comment explains that _build_server_params runs the SSRF egress guard before attaching credentials, which is correct. Consider adding a regression test that asserts validate_egress_url is called with the resolved URL before _build_auth_headers runs, so a future refactor cannot accidentally swap the order and attach credentials to a rejected host.

…/11/12/17)

Independent of the other pentest PRs — targets release, merges in any order.

Four code paths dereference a URL that a template author, a lead payload or the
LLM can influence, and none checked where it pointed: MCP server URLs (hit at
flow-build time with decrypted tenant credentials attached), HttpRequestExecutor,
the reporting-webhook sender, and the three telephony recording downloads.

Adds one shared guard (app/core/security/ssrf.py) rather than four checks:
resolve the host, deny private/loopback/link-local/metadata, require https by
default, and revalidate on every redirect hop so a public host cannot 30x the
request onto an internal target. Credentials are stripped when a redirect leaves
the allow-list.

PT-12 lives here rather than with the telephony signature work because the
recording downloads are an egress concern: the fix is ssrf_safe_request around a
credentialed fetch, so it shares all its code with this guard and none with
callback authentication.

Two deliberate choices:
- validate_egress_url runs BEFORE _build_auth_headers in the MCP path, so a
  hostile server URL never gets the tenant's credentials attached even briefly.
- the webhook sender passes allow_http=True. PT-11 is about the resolved
  address, not the scheme, and this path never restricted the scheme before;
  https-only here would silently drop outcome webhooks for every tenant still on
  http. Plaintext delivery is logged instead.

_build_server_params becomes async because resolution is a DNS round-trip.

Tests: 11 reserved ranges, scheme and hostname cases, per-hop redirect
revalidation, credential stripping. 942 pass on this branch.

Review round 2 — addresses the four CRITICAL and two MAJOR findings:

- http_requester: catch SSRFError explicitly and abort. It is not an
  aiohttp.ClientError, so it fell into the generic handler and the request body
  was replayed at a blocked target up to max_retries times. This also replaces
  the now-unreachable aiohttp.TooManyRedirects branch, since ssrf_safe_request
  follows redirects itself.
- mcp: the direct-HTTP tool handler re-validates at CALL time. The URL was only
  checked at flow-build time, but this handler runs per invocation and attaches
  tenant credentials — a name that resolved public at build and internal at call
  (rebinding) went straight through. Validation runs before the credential
  headers are assembled, and follow_redirects=False is now explicit.
- common: the signed reporting webhook passes max_redirects=0. A tenant-configured
  endpoint has no reason to redirect, and each hop replayed the lead payload to a
  host the tenant never configured. SSRFError also aborts instead of retrying.
- ssrf: allow_redirects supplied by a caller is popped rather than colliding with
  the explicit argument (was TypeError); 301/302/303 rewrite non-GET to GET and
  drop the body, with only 307/308 preserving them; exhausting max_redirects
  raises instead of yielding the final 3xx as if it were the answer.
- docstring corrected — the module does depend on aiohttp for ssrf_safe_request;
  only the validation half is client-agnostic.
- tests: 6 new cases covering redirect method/body semantics, the hop-budget
  contract, the kwargs collision, max_redirects=0, and the MCP call-time guard;
  plus an autouse fixture pinning SSRF_ALLOW_PRIVATE_EGRESS off so a developer
  with it enabled locally does not silently invert every assertion.

948 tests pass on this branch.
@murdore
murdore force-pushed the fix/pt-ssrf-egress branch from 76a6a4f to 6baef03 Compare August 17, 2026 07:22
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.

3 participants