Security: SSRF guard on fetch_url + single-use tool-approval tokens #2

Closed
Athena wants to merge 0 commits from Athena/NexusOS:harden/2-tool-agent-safety into main
Contributor

Summary

Two tool/agent-layer fixes:

SSRF guard on fetch_url (synapse/tools.py): the model-callable fetch_url tool would fetch any URL, letting a prompt-injected model steer requests at 127.0.0.1:11434 (Ollama admin API), cloud metadata (169.254.169.254), or LAN hosts. Every A/AAAA record of the target host — and of every redirect hop — is now resolved and checked against loopback / private / link-local / multicast / reserved ranges before connecting (IPv4-mapped IPv6 is unwrapped so ::ffff:127.0.0.1 can't sneak through). Redirects are followed manually with a hop cap so a public URL can't 302 its way to an internal address.

Single-use tool-approval tokens (synapse/chat.py, synapse/main.py, interface/web/src/Chatbot.jsx): /chat/approve accepted any request that knew a conversation id, which is guessable/enumerable — so anything that could reach the port could approve someone else's pending host action. Each approval prompt now carries a secrets.token_urlsafe(32) token delivered only on the owning SSE stream; /chat/approve requires it and compares in constant time.

Testing

  • Unit-tested _ip_is_blocked / _ssrf_guard across loopback, RFC1918, link-local, metadata IP, IPv4-mapped IPv6, and public addresses.
  • End-to-end fetch_url against local redirector: direct private targets and redirect-to-private both blocked; public fetches work.
  • /chat/approve without/with wrong token returns 403; correct token resolves the pending action.

Stack note

This is PR 2 of 3, stacked on "bind services to loopback with Host + CORS allowlists". Until that PR merges, this diff also shows its commit — review only the latest commit here, or merge in order.

## Summary Two tool/agent-layer fixes: **SSRF guard on `fetch_url`** (`synapse/tools.py`): the model-callable `fetch_url` tool would fetch any URL, letting a prompt-injected model steer requests at `127.0.0.1:11434` (Ollama admin API), cloud metadata (`169.254.169.254`), or LAN hosts. Every A/AAAA record of the target host — and of **every redirect hop** — is now resolved and checked against loopback / private / link-local / multicast / reserved ranges before connecting (IPv4-mapped IPv6 is unwrapped so `::ffff:127.0.0.1` can't sneak through). Redirects are followed manually with a hop cap so a public URL can't 302 its way to an internal address. **Single-use tool-approval tokens** (`synapse/chat.py`, `synapse/main.py`, `interface/web/src/Chatbot.jsx`): `/chat/approve` accepted any request that knew a conversation id, which is guessable/enumerable — so anything that could reach the port could approve someone else's pending host action. Each approval prompt now carries a `secrets.token_urlsafe(32)` token delivered only on the owning SSE stream; `/chat/approve` requires it and compares in constant time. ## Testing - Unit-tested `_ip_is_blocked` / `_ssrf_guard` across loopback, RFC1918, link-local, metadata IP, IPv4-mapped IPv6, and public addresses. - End-to-end `fetch_url` against local redirector: direct private targets and redirect-to-private both blocked; public fetches work. - `/chat/approve` without/with wrong token returns 403; correct token resolves the pending action. ## Stack note This is **PR 2 of 3**, stacked on "bind services to loopback with Host + CORS allowlists". Until that PR merges, this diff also shows its commit — review only the latest commit here, or merge in order.
Athena added 2 commits 2026-08-07 14:50:12 +00:00
The Synapse backend and memory service bound 0.0.0.0 with wildcard CORS and no
auth, exposing the full unauthenticated admin/data API to the LAN. Default the
uvicorn bind to 127.0.0.1 (NEXUS_BIND_HOST override), scope CORS to known local
origins instead of "*", and add TrustedHostMiddleware to reject foreign Host
headers (which defeats DNS-rebinding, something same-origin CORS cannot stop).

NEXUS_ALLOWED_HOSTS / NEXUS_ALLOWED_ORIGINS allow opt-in LAN exposure, intended
to be paired with real authentication.

Co-authored-by: Cursor <cursoragent@cursor.com>
Two tool/agent-layer hardening changes:

* fetch_url now resolves the target host and refuses to connect if any
  resolved address is loopback, private (RFC1918/ULA), link-local (incl. the
  169.254.169.254 cloud-metadata endpoint), multicast, reserved, or
  unspecified. IPv4-mapped IPv6 is unwrapped first, and the guard re-runs on
  every redirect hop so a public URL cannot 302 its way to an internal target.

* /chat/approve now requires a single-use token minted when the stream pauses
  for approval and delivered only in that stream's tool_request event, compared
  in constant time. Previously the pending approval was keyed solely on a
  client-supplied conversation_id, so anyone who could enumerate a
  conversation_id could approve another client's pending action.

The frontend threads the token from the tool_request event into the approve
call.

Co-authored-by: Cursor <cursoragent@cursor.com>
enderofwings marked the pull request as work in progress 2026-08-07 15:36:56 +00:00
enderofwings closed this pull request 2026-08-07 15:52:44 +00:00
enderofwings marked the pull request as ready for review 2026-08-07 15:59:38 +00:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: enderofwings/NexusOS#2