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
+10 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio as _asyncio
import json as _json
import secrets as _secrets
import uuid as _uuid
from collections import Counter as _Counter
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
@@ -508,12 +509,20 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
@app.post("/chat/approve")
async def chat_approve(payload: Dict[str, Any] = Body(...)):
"""Resolve a pending per-call tool approval. `decisions` maps tool name ->
bool; the awaiting chat stream resumes and runs the approved actions."""
bool; the awaiting chat stream resumes and runs the approved actions.
The `token` (issued in the stream's tool_request event) is required: without
it, anyone who can guess/enumerate a conversation_id could approve another
client's pending action. Compared in constant time."""
conversation_id = payload.get("conversation_id") or ""
token = payload.get("token") or ""
decisions = payload.get("decisions") or {}
waiter = _chat.pending_approvals.get(conversation_id)
if not waiter:
raise HTTPException(status_code=404, detail="no pending approval for this conversation")
expected = waiter.get("token") or ""
if not token or not _secrets.compare_digest(str(token), str(expected)):
raise HTTPException(status_code=403, detail="invalid or missing approval token")
waiter["decisions"] = {k: bool(v) for k, v in decisions.items()}
waiter["event"].set()
return {"status": "resumed"}