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
+9 -2
View File
@@ -18,6 +18,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
const [images, setImages] = useState([]); // {name, b64} for vision models
const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
const [pendingApproval, setPendingApproval] = useState(null); // [{name, arguments}] awaiting yes/no
const [approvalToken, setApprovalToken] = useState(null); // single-use token authorizing /chat/approve
const [editingIdx, setEditingIdx] = useState(null); // user message being edited
const [editText, setEditText] = useState("");
const [listening, setListening] = useState(false); // mic dictation active
@@ -286,7 +287,11 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
continue;
}
if (pendingEventType === "tool_request") {
try { setPendingApproval(JSON.parse(payload)); } catch { /* ignore */ }
try {
const parsed = JSON.parse(payload);
setPendingApproval(parsed.actions || []);
setApprovalToken(parsed.token || null);
} catch { /* ignore */ }
pendingEventType = null;
continue;
}
@@ -401,13 +406,15 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
// Approve or deny the pending action tool(s); the open chat stream resumes.
const resolveApproval = async (approve) => {
const req = pendingApproval || [];
const token = approvalToken;
setPendingApproval(null);
setApprovalToken(null);
const decisions = {};
req.forEach(a => { decisions[a.name] = approve; });
try {
await fetch(`${API_BASE}/chat/approve`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ conversation_id: conversationId, decisions }),
body: JSON.stringify({ conversation_id: conversationId, token, decisions }),
});
} catch { /* ignore */ }
};