Skip to content

fix(guardrails): route custom-code http_request() through SSRF validation - #32870

Closed
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
litellm_guardrail_http_request_ssrf
Closed

devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
litellm_guardrail_http_request_ssrf

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #32862

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Reproduced end to end against a live proxy on localhost:4000. Setup is a loopback "internal" service standing in for a cloud metadata / internal endpoint, and a custom-code guardrail (mode: pre_call) whose apply_guardrail calls http_request() at that internal URL and blocks with the fetched result, so whether the SSRF succeeded is visible in the chat response

Internal service (returns a fake secret if reached):

# 127.0.0.1:9111, logs "INTERNAL SERVICE HIT" on every request
SECRET = "SECRET_INTERNAL_IAM_CREDENTIALS_do_not_leak_9f3a2b"

Guardrail in the proxy config:

guardrails:
  - guardrail_name: "ssrf-probe"
    litellm_params:
      guardrail: custom_code
      mode: "pre_call"
      default_on: true
      custom_code: |
        async def apply_guardrail(inputs, request_data, input_type):
            resp = await http_get("http://127.0.0.1:9111/latest/meta-data/iam/security-credentials/")
            body = json_stringify(resp)
            if regex_match(body, "SECRET_INTERNAL"):
                return block("SSRF_SUCCEEDED_leaked=" + body)
            return block("SSRF_BLOCKED_resp=" + body)

Before the fix (vulnerable code at 109193f26a) the guardrail reaches the internal service and exfiltrates its response; the internal service hit counter goes from 1 to 2:

$ curl -s -X POST http://127.0.0.1:4000/v1/chat/completions \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"model":"anthropic-haiku","messages":[{"role":"user","content":"hi"}]}'

{"...":"...","choices":[{"finish_reason":"content_filter","message":{"content":
"SSRF_SUCCEEDED_leaked={\"status_code\": 200, \"body\":
\"SECRET_INTERNAL_IAM_CREDENTIALS_do_not_leak_9f3a2b\", \"success\": true,
\"error\": null}", ...}}]}

# internal-service hits before this call: 1 / after: 2   (SSRF reached it)

After the fix (this branch at d9a7666cb1) the same request is stopped by validate_url() inside async_safe_request, no outbound request is made, and the internal service hit counter stays put:

$ curl -s -X POST http://127.0.0.1:4000/v1/chat/completions \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"model":"anthropic-haiku","messages":[{"role":"user","content":"hi"}]}'

{"...":"...","choices":[{"finish_reason":"content_filter","message":{"content":
"SSRF_BLOCKED_resp={\"status_code\": 0, \"body\": null, \"success\": false,
\"error\": \"Blocked by SSRF protection: URL targets a blocked address
(127.0.0.1). If this is a legitimate internal service, add the host to
`user_url_allowed_hosts` in general_settings.\"}", ...}}]}

# internal-service hits before this call: 2 / after: 2   (never reached)

The same block applies to the cloud metadata address from the report (http://169.254.169.254/latest/meta-data/iam/security-credentials/) and to every verb http_request() supports, which the added unit tests cover directly

Type

🐛 Bug Fix

Changes

The http_request() primitive exposed to custom-code guardrails (litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py) only ran the target through is_valid_url(), which is a pure urlparse syntax check. It never resolved DNS, never consulted the private/cloud-metadata blocklist, and let the underlying client follow redirects on its own (the cached AsyncHTTPHandler is created with follow_redirects=True). That let guardrail code reach 169.254.169.254, internal services, and IAM credential endpoints, i.e. full SSRF through the guardrail HTTP interface

The existing SSRF-safe helper async_safe_get in url_utils.py only covers GET, but http_request() also supports POST/PUT/DELETE/PATCH, so there was no drop-in for the other verbs. This adds a method-generic sibling async_safe_request(client, method, url, ...) that reuses the exact same protection async_safe_get already applies (resolve-and-rewrite to the validated IP, per-hop redirect validation, Host header preservation, and the litellm.user_url_validation master switch). It operates on the raw httpx.AsyncClient because the per-hop follow_redirects=False control isn't available on the higher-level handler's non-GET methods

# primitives.http_request(), after the method/timeout checks
json_body, data_body = _prepare_http_body(body)
try:
    response = await async_safe_request(
        client.client, method, url,
        headers=headers, json=json_body, data=data_body, timeout=timeout,
    )
    return _http_success_response(response)
except SSRFError as e:
    return _http_error_response(f"Blocked by SSRF protection: {e}")

Because the raw client no longer calls raise_for_status(), the previous httpx.HTTPStatusError branch is gone; non-2xx responses now flow straight into _http_success_response (which already sets success=False for them), and the now-unused _execute_http_request helper is removed

Behavior for legitimate public URLs is unchanged: HTTPS with SSL verification keeps its hostname (TLS cert binding defeats rebinding), HTTP is rewritten to the validated IP with the original hostname carried in the Host header, and admins can still opt out or allowlist internal hosts via user_url_validation / user_url_allowed_hosts

Tests cover the new helper directly (blocks private and cloud-metadata IPs for non-GET verbs, validates every redirect hop, honors the master switch) and the primitive end to end (metadata IP blocked with zero outbound requests, a public host 302-redirecting to a private target blocked at the redirect hop, and the happy path still issuing the validated request to the resolved IP with the original Host header)

Link to Devin session: https://app.devin.ai/sessions/047926398eca4c999369b5f87d67cbae

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a method-generic async_safe_request helper in url_utils.py and routes the custom-code guardrail's http_request() primitive through it, applying full URL validation and per-hop redirect checking on every outbound call.

  • async_safe_request (url_utils.py): Mirrors async_safe_get but accepts any HTTP method. Correctly adds an or {} guard on the popped headers kwarg that the existing async_safe_get/safe_get functions are missing, preventing a TypeError when callers pass headers=None. Operates on the raw httpx.AsyncClient to maintain per-hop follow_redirects=False control. One gap: the redirect loop does not change the method to GET for 301/302/303 responses as RFC 7231 requires, which can cause POST to be replayed on redirect targets.
  • primitives.http_request() (primitives.py): Replaces the removed _execute_http_request helper with a call to async_safe_request(client.client, ...), adds an SSRFError catch, and cleanly removes the dead HTTPStatusError branch (the raw client never calls raise_for_status()).
  • Tests: Four new unit tests in test_url_utils.py and four end-to-end integration tests in the new test_custom_code_primitives.py cover the protection scenarios with fully-mocked DNS and HTTP clients.

Confidence Score: 4/5

Safe to merge with awareness of one behavioral change in redirect handling.

The core security hardening is correctly implemented and well-tested. The one finding is that the redirect loop always preserves the original HTTP method for 301/302/303 responses, where httpx built-in redirect logic would have changed POST to GET. This is a functional deviation from RFC 7231 and from the old code behavior, but it is unlikely to affect most callers and does not weaken the protection.

litellm/litellm_core_utils/url_utils.py — the async_safe_request redirect loop (lines 458-463) does not downgrade POST to GET on 301/302/303 responses.

Important Files Changed

Filename Overview
litellm/litellm_core_utils/url_utils.py Adds async_safe_request, a method-generic sibling of async_safe_get with identical SSRF protections; correctly adds or {} guard for None headers not present in async_safe_get; redirect loop always preserves the original HTTP method even for 301/302/303 (deviation from RFC 7231 POST to GET semantics)
litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py Routes http_request() through async_safe_request instead of the removed _execute_http_request helper; adds SSRFError catch; correctly accesses client.client for per-hop redirect control; HTTPStatusError branch cleanly removed since the raw client never calls raise_for_status()
tests/test_litellm/litellm_core_utils/test_url_utils.py New TestAsyncSafeRequest class covers private IP, cloud-metadata, per-hop redirect validation, and master-switch bypass via monkeypatched DNS and fake clients with no real network calls
tests/test_litellm/proxy/guardrails/test_custom_code_primitives.py End-to-end tests for http_request() cover cloud-metadata blocking, private IP blocking, redirect-to-private blocking, and happy-path validation with Host header; all tests are fully mocked

Reviews (1): Last reviewed commit: "fix(guardrails): route custom-code http_..." | Re-trigger Greptile

Comment on lines +458 to +463
if not response.is_redirect:
return response
# Resolve the next hop against the ORIGINAL (pre-rewrite) URL so
# relative Location headers keep the original hostname.
url = _extract_redirect_url(response, url)
raise SSRFError("Too many redirects")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Non-GET redirects always preserve the original method

async_safe_request uses the original method on every hop in the redirect loop. For 301/302/303 responses, RFC 7231 mandates that POST (and other unsafe methods) be reissued as GET to the redirect target; only 307/308 should preserve the method. The old code delegated to httpx with follow_redirects=True, which handled this automatically. Callers that POST to a URL returning a 301/302/303 will now send POST to the redirect target instead of GET, which can result in 405 errors or unintended double-POSTs against external APIs. Consider adding if response.status_code in (301, 302, 303) and method != "GET": method = "GET"; kwargs.pop("json", None); kwargs.pop("data", None) before advancing url.

@codecov

codecov Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/litellm_core_utils/url_utils.py 92.30% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_guardrail_http_request_ssrf (d9a7666) with litellm_internal_staging (4baf326)

Open in CodSpeed

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same underlying fix as #32897 (different issue number, #32862 here vs #32889 there) and #32866 — flagging the duplication, left the fuller comparison on #32897 since I worked through all three together.

Specifically on this one: it shares #32897's design (raw httpx.AsyncClient passed through client.client, client.request(...) dispatch) but does not carry over #32897's cross-origin credential-header stripping on redirect — a validated redirect to a different origin here would still forward the original Authorization/Cookie headers unchanged. Also worth noting this PR removes the old except httpx.HTTPStatusError branch that returned the response even for non-2xx status — did not verify whether anything downstream relied on that behavior for a specific status code, so flagging rather than asserting it's wrong.

@mateo-berri

Copy link
Copy Markdown
Contributor

Closing as superseded by #43280, which routes http_request through the shared SSRF validator with per-hop redirect checks and adds an execution timeout.

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.

4 participants