feat(security): SSRF guard on fetch_url + single-use tool-approval tokens

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>
This commit is contained in:
2026-08-07 09:41:09 -05:00
co-authored by Cursor
parent fe12eb1821
commit 1f6beed0d2
4 changed files with 104 additions and 16 deletions
+15 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import json as _json
import logging
import secrets
import threading
from typing import AsyncGenerator, Dict, List, Optional, Any
@@ -171,12 +172,20 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
if policy == "ask" and action_calls:
event = asyncio.Event()
pending_approvals[conversation_id] = {"event": event, "decisions": {}}
yield "__approve__" + _json.dumps([
{"name": c.get("function", {}).get("name", ""),
"arguments": c.get("function", {}).get("arguments")}
for c in action_calls
])
# Single-use capability token, delivered only to the client that owns
# this stream. /chat/approve requires it, so knowing the (guessable,
# enumerable) conversation_id is no longer enough to approve someone
# else's pending action.
token = secrets.token_urlsafe(32)
pending_approvals[conversation_id] = {"event": event, "decisions": {}, "token": token}
yield "__approve__" + _json.dumps({
"token": token,
"actions": [
{"name": c.get("function", {}).get("name", ""),
"arguments": c.get("function", {}).get("arguments")}
for c in action_calls
],
})
try:
await asyncio.wait_for(event.wait(), timeout=_APPROVAL_TIMEOUT)
decisions = pending_approvals[conversation_id]["decisions"]