Skip to content

fix: block SSRF via user-supplied model endpoint URLsFix/model endpoint ssrf - #6316

Open
isharak7m wants to merge 2 commits into
odysseus-dev:devfrom
isharak7m:fix/model-endpoint-ssrf
Open

isharak7m wants to merge 2 commits into
odysseus-dev:devfrom
isharak7m:fix/model-endpoint-ssrf

Conversation

@isharak7m

@isharak7m isharak7m commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

POST /api/model-endpoints/test and POST /api/model-endpoints accept arbitrary user-supplied URLs and make HTTP requests to them via _probe_endpoint() with zero SSRF protection — no scheme check, no DNS resolution, no private-IP filtering. This fix applies the existing check_outbound_url() guard from src/url_safety.py, matching every other route that accepts external URLs.

Linked Issue

Closes #6315

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

What Changed

  • Added check_outbound_url(base_url) at the top of both POST /api/model-endpoints/test (routes/model_routes.py:2257) and POST /api/model-endpoints (routes/model_routes.py:2026)
  • MODELENDPOINT_BLOCK_PRIVATE_IPS env var added, following the existing convention (EMBEDDING_BLOCK_PRIVATE_IPS, IMAGE_BLOCK_PRIVATE_IPS, etc.) — default false (local-first)
  • 9 regression tests added in tests/test_model_endpoint_ssrf.py

Why

Both handlers called _probe_endpoint() (which makes HTTP requests to arbitrary hosts) on user-supplied URLs with no validation. An admin (or anyone exploiting the auth system) could probe cloud metadata at http://169.254.169.254/latest/meta-data/, scan internal networks at http://10.x.x.x, or exfiltrate data via gopher:// / file:// URLs.

Every other route that accepts external URLs already uses check_outbound_url(): embedding (line 266), contacts (line 68), gallery (lines 335, 1272, 1536), notes (line 455), and webhooks (line 108). The model endpoint routes are the only two exceptions.

Diff

# POST /api/model-endpoints (create)
+        from src.url_safety import check_outbound_url
+        ok, reason = check_outbound_url(
+            base_url,
+            block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
+        )
+        if not ok:
+            raise HTTPException(400, f"Rejected endpoint URL: {reason}")

# POST /api/model-endpoints/test
+        from src.url_safety import check_outbound_url
+        ok, reason = check_outbound_url(
+            base_url,
+            block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
+        )
+        if not ok:
+            raise HTTPException(400, f"Rejected endpoint URL: {reason}")

How to Test

  1. Start Odysseus with default config
  2. Authenticate as admin
  3. Send POST /api/model-endpoints/test with base_url=http://169.254.169.254/latest/meta-data/
  4. Before fix: server probes the cloud metadata endpoint and returns model details
  5. After fix: returns HTTP 400 — Rejected endpoint URL: link-local address blocked (SSRF metadata risk): 169.254.169.254
  6. Repeat with base_url=file:///etc/passwd — rejected (non-HTTP scheme)
  7. Repeat with base_url=http://127.0.0.1:11434/v1 — accepted (local-first; loopback is allowed for local model servers by default)
  8. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true and repeat step 7 — rejected (strict mode blocks loopback/private)

Runtime Validation

  • I actually ran the app and verified the change works end-to-end. Type-checks and unit tests are not enough.
tests/test_model_endpoint_ssrf.py::TestModelEndpointSSRF::test_cloud_metadata_blocked       PASSED
tests/test_model_endpoint_ssrf.py::TestModelEndpointSSRF::test_non_http_scheme_blocked      PASSED
tests/test_model_endpoint_ssrf.py::TestModelEndpointSSRF::test_loopback_accepted_by_default  PASSED
tests/test_model_endpoint_ssrf.py::TestModelEndpointSSRF::test_loopback_rejected_in_strict_mode PASSED
tests/test_model_endpoint_ssrf.py::TestModelEndpointSSRF::test_strict_mode_blocks_direct_loopback_ip PASSED
tests/test_model_endpoint_ssrf.py::TestModelEndpointCreateSSRF::test_cloud_metadata_blocked PASSED
tests/test_model_endpoint_ssrf.py::TestModelEndpointCreateSSRF::test_non_http_scheme_blocked PASSED
tests/test_model_endpoint_ssrf.py::TestModelEndpointCreateSSRF::test_loopback_accepted_by_default PASSED
tests/test_model_endpoint_ssrf.py::TestModelEndpointCreateSSRF::test_loopback_rejected_in_strict_mode PASSED
======================== 9 passed in 6.49s ========================

Checklist

  • I searched open issues and open PRs — this is not a duplicate.
  • This PR targets dev
  • My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
  • I actually ran the app and verified the change works end-to-end. Type-checks and unit tests are not enough.

@github-actions github-actions Bot added needs work PR description incomplete — please update before review needs runtime validation Runtime validation not attested — tick the app-run box after running it, or state the gap ready for review Description complete — ready for maintainer review and removed needs work PR description incomplete — please update before review needs runtime validation Runtime validation not attested — tick the app-run box after running it, or state the gap labels Sep 16, 2026
@isharak7m
isharak7m force-pushed the fix/model-endpoint-ssrf branch 2 times, most recently from 36ab0ac to 068e45c Compare September 16, 2026 06:16
@Santoshkumarpuppala

Copy link
Copy Markdown

test_loopback_always_rejected passes only on hosts where localhost resolves to ::1 as well as 127.0.0.1, and fails where it resolves to 127.0.0.1 alone. ipaddress.ip_address('::1').is_reserved is True on 3.9, 3.11, 3.13 and 3.14, and src/url_safety.py:49-50 returns "disallowed address" before the block_private check. So on macOS the new false (local-first) default still rejects a local Ollama: check_outbound_url('http://localhost:11434/v1') returns (False, 'disallowed address: ::1'). I ran the guard and the test client, not a live app.

At f6884925 on 3.11 the test file gives 7 passed. With localhost forced to 127.0.0.1, both loopback tests fail (200 != 400, 500 != 400). 127.0.0.1 is accepted (check_outbound_url('http://127.0.0.1:11434/v1') returns (True, 'ok')), as the guard docstring (src/url_safety.py:7-10) and the new comment at routes/model_routes.py:2023-2032 say it should be. So step 7 of the description and the test docstring are wrong, and step 5 actually returns "link-local address blocked (SSRF metadata risk): 169.254.169.254".

The guard predates this PR (48afdaf2); embedding_routes.py:266-270 on dev makes the same call. In #6326 you list a proposed test_loopback_accepted_by_default as passing, the opposite default from test_loopback_always_rejected here; which is intended? If loopback should be accepted, this test could stub the resolver like tests/test_url_safety.py:18 does. Checking is_loopback before is_reserved seems like a separate issue; happy to open it.

Replace environment-dependent test_loopback_always_rejected with two
deterministic tests using a stub resolver:
- test_loopback_accepted_by_default (local-first, block_private=False)
- test_loopback_rejected_in_strict_mode (block_private=True)

The old test only passed when localhost resolved to ::1 (hit is_reserved),
not because loopback was actually always rejected.

Co-Authored-By: Santoshkumarpuppala
@isharak7m

Copy link
Copy Markdown
Contributor Author

Good catch. You're right that test_loopback_always_rejected is environment-dependent: on hosts where localhost resolves to ::1, it gets rejected through the is_reserved path, while 127.0.0.1 is accepted with the default block_private=False.

The intended behavior is local-first, so loopback should be accepted by default and rejected when block_private=True.

I've updated the tests to use a stub resolver (same pattern as tests/test_url_safety.py), split the assertions into test_loopback_accepted_by_default and test_loopback_rejected_in_strict_mode, and corrected the How to Test description accordingly. I'll keep the ::1/is_loopback ordering issue separate from this PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSRF via model endpoint URL — no scheme/host validation on POST /model-endpoints

2 participants