forked from enderofwings/NexusOS
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:
+10
-1
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user