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.
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.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Two tool/agent-layer fixes:
SSRF guard on
fetch_url(synapse/tools.py): the model-callablefetch_urltool would fetch any URL, letting a prompt-injected model steer requests at127.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.1can'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/approveaccepted 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 asecrets.token_urlsafe(32)token delivered only on the owning SSE stream;/chat/approverequires it and compares in constant time.Testing
_ip_is_blocked/_ssrf_guardacross loopback, RFC1918, link-local, metadata IP, IPv4-mapped IPv6, and public addresses.fetch_urlagainst local redirector: direct private targets and redirect-to-private both blocked; public fetches work./chat/approvewithout/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.
Pull request closed