Skip to content

Harden MCP server SSRF and long-poll surfaces - #1511

Open
daiv-agent[bot] wants to merge 3 commits into
mainfrom
fix/mcp-ssrf-and-poll-hardening
Open

Harden MCP server SSRF and long-poll surfaces#1511
daiv-agent[bot] wants to merge 3 commits into
mainfrom
fix/mcp-ssrf-and-poll-hardening

Conversation

@daiv-agent

@daiv-agent daiv-agent Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Hardens the MCP server surfaces against SSRF and DoS: member-controlled (user-scoped) MCP servers and non-admin test probes can no longer target internal/loopback/link-local addresses, OAuth client registration enforces RFC 8252 loopback-only plain-http redirect URIs, and the job-status long-poll is rate-limited and gives up quickly on unknown job ids.

Key Changes:

  • mcp_servers/validators.py: new is_internal_network_target SSRF guard — flags IP literals in private/loopback/link-local/reserved/unspecified/multicast ranges, the localhost name, and hostnames that resolve to such IPs (best-effort DNS via module-level _resolve_host_ips, so tests can monkeypatch the resolver).
  • mcp_servers/forms.py: MCPServerForm.clean_url rejects internal-network targets for user-scoped (Scope.USER) servers; global/admin-configured rows keep the permissive validate_http_url (internal MCP servers are a legitimate deployment shape).
  • mcp_servers/views.py: MCPServerTestView now runs validate_http_url and rejects internal targets for non-admin users before probing, so a member "Test connection" cannot be an SSRF primitive from the app host.
  • mcp_server/api/schemas.py: ClientRegistrationRequest enforces RFC 8252 via new _is_loopback_host — plain http redirect URIs are only accepted for localhost or loopback IP literals; all other hosts must register https.
  • mcp_server/server.py: get_job_status(wait=true) long-poll now routes through _wait_for_job, which applies the shared per-user JobsRateThrottle budget (same bucket as submit_job) before polling; a job id that never appears in the DB is declared not found after the new 5s NOT_FOUND_GRACE instead of pinning an ASGI connection for the full 10-minute MAX_POLL_DURATION.
  • Tests: private/public URL matrices and DNS-rebinding cases in test_validators.py and test_forms.py, plus an autouse _no_dns_resolution fixture in the mcp_servers conftest so form/view tests never hit real DNS.

⚠️ DAIV can make mistakes. Please review the changes and merge the MR if everything looks good.
🔗 Generated by DAIV — view sessions


💡 Instructions for the reviewer:

  • 💬 Just leave comments/reviews mentioning @daiv-agent to get DAIV to address them for you.

@daiv-agent daiv-agent Bot added the daiv Delegate issues to DAIV agent label Aug 19, 2026
@srtab

srtab commented Aug 21, 2026

Copy link
Copy Markdown
Owner

@daiv-agent fix the pipeline

@daiv-agent

daiv-agent Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

The changes are pure formatting (ruff collapsed multi-line expressions onto single lines). These match exactly what the CI's ruff format --check demanded.

The pipeline failure was solely a ruff format issue in the three MCP server files added by this branch. I reformatted them with ruff format and verified the full make lint (ruff check + format + pyproject-fmt + djade) passes. The changes are whitespace-only collapses — no logic touched.

@srtab

srtab commented Aug 21, 2026

Copy link
Copy Markdown
Owner

@daiv-agent now the unit tests are failing

@daiv-agent

daiv-agent Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed. Summary of the three test failures and their fixes:

1. test_validators.py — two SSRF-guard assertions ([IP_ADDRESS] cases)

On Python 3.14, CPython's ipaddress now aligns is_private with the IANA special-uses registry, which includes the documentation ranges (TEST-NET-1/2/3: [IP_ADDRESS], [IP_ADDRESS], [IP_ADDRESS]). So ipaddress.ip_address("[IP_ADDRESS]").is_private is now True — the tests' assumption that TEST-NET-1 is "public, not private" no longer holds, and is_internal_network_target correctly returned True.

The SSRF guard's intent (per its docstring) is to block private/loopback/link-local targets, and the test just needed a genuinely public IP to assert the "public → allowed" path. I replaced the TEST-NET-1 literal with [IP_ADDRESS] (Google DNS, routable public) in both the _PUBLIC_URLS parametrize case and the DNS-rebinding-to-public test.

2. test_forms.py::test_user_scoped_form_rejects_hostname_resolving_to_privateDatabase access not allowed

This test calls form.is_valid() (a ModelForm, which runs validate_unique → DB) but was missing the @pytest.mark.django_db mark that all its sibling tests carry. Added the mark.

Verified: all 301 tests/unit_tests/mcp_servers/ tests pass, and ruff format/check are clean on the edited files. No production code changed — only test fixtures were wrong for Python 3.14 / missing a DB mark.

@srtab srtab self-assigned this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

daiv Delegate issues to DAIV agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant