263 lines
11 KiB
Python
263 lines
11 KiB
Python
"""browser_console(expression=...) policy: SSRF guard gating, private-URL probes,
|
|
and the opt-in sensitive-primitive denylist.
|
|
|
|
Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``)
|
|
so ``patch("tools.browser_tool.X")`` in tests keeps working; this module must not
|
|
import ``tools.browser_tool`` at import time (cycle).
|
|
"""
|
|
|
|
import re
|
|
from typing import Optional
|
|
from tools.browser_tool_origin import origin_module as _origin
|
|
|
|
|
|
def _eval_ssrf_guard_active(effective_task_id: str) -> bool:
|
|
"""Return True when eval-driven private-network access must be guarded.
|
|
|
|
Matches the gating used by ``browser_navigate`` / ``browser_snapshot`` /
|
|
``browser_vision``: the SSRF guard is only meaningful for non-local
|
|
backends (cloud browser, or a containerized terminal whose browser-on-host
|
|
can reach internal networks the terminal can't), and is skipped for local
|
|
sidecar sessions and when ``allow_private_urls`` is set.
|
|
"""
|
|
_bt = _origin()
|
|
return (
|
|
not _bt._is_local_backend()
|
|
and not _bt._is_local_sidecar_key(effective_task_id)
|
|
and not _bt._allow_private_urls()
|
|
)
|
|
|
|
|
|
# URL-shaped literals embedded in a JS expression (http/https only). Used to
|
|
# pre-screen ``browser_console(expression=...)`` calls that fetch/XHR/navigate
|
|
# to a private host directly — that path never updates ``location.href`` so the
|
|
# post-eval page-URL recheck below can't see it.
|
|
_JS_URL_LITERAL_RE = re.compile(r"""https?://[^\s'"`)\]<>]+""", re.IGNORECASE)
|
|
|
|
|
|
def _expression_targets_private_url(expression: str) -> Optional[str]:
|
|
"""Return the first private/always-blocked URL literal in a JS expression.
|
|
|
|
Best-effort: scans for ``http(s)://...`` literals (fetch/XHR/navigation
|
|
targets the agent may have embedded) and returns the first one that targets
|
|
a private/internal address or the always-blocked cloud-metadata floor.
|
|
Returns ``None`` when no such literal is found.
|
|
"""
|
|
_bt = _origin()
|
|
if not isinstance(expression, str):
|
|
return None
|
|
for match in _JS_URL_LITERAL_RE.findall(expression):
|
|
candidate = match.rstrip(".,;")
|
|
if _bt._is_always_blocked_url(candidate) or not _bt._is_safe_url(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _current_page_private_url(effective_task_id: str) -> Optional[str]:
|
|
"""Return the current page URL when it targets a private/internal address.
|
|
|
|
Reads ``window.location.href`` via a low-cost eval and returns it when the
|
|
page has been navigated (e.g. via ``location.href = '...'`` in a prior
|
|
eval) to an address the SSRF guard would reject. Returns ``None`` when the
|
|
page is public, the URL can't be determined, or the check errors (fail-open
|
|
on probe failure, matching the snapshot/vision guards).
|
|
"""
|
|
_bt = _origin()
|
|
try:
|
|
url_result = _bt._run_browser_command(
|
|
effective_task_id, "eval", ["window.location.href"],
|
|
timeout=5, _engine_override="auto",
|
|
)
|
|
if url_result.get("success"):
|
|
current_url = (
|
|
url_result.get("data", {}).get("result", "")
|
|
.strip().strip('"').strip("'")
|
|
)
|
|
if current_url and (
|
|
_bt._is_always_blocked_url(current_url) or not _bt._is_safe_url(current_url)
|
|
):
|
|
return current_url
|
|
except Exception as exc:
|
|
_bt.logger.debug("_current_page_private_url: probe failed (%s)", exc)
|
|
return None
|
|
|
|
|
|
_RISKY_BROWSER_EVAL_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
(re.compile(r"\bdocument\s*\.\s*cookie\b", re.I), "document.cookie"),
|
|
(re.compile(r"\b(?:localStorage|sessionStorage)\b", re.I), "web storage"),
|
|
(re.compile(r"\bindexedDB\b", re.I), "IndexedDB"),
|
|
(re.compile(r"\bcaches\s*\.\s*(?:open|match|keys)\b", re.I), "Cache Storage"),
|
|
(re.compile(r"\bnavigator\s*\.\s*(?:clipboard|credentials|serviceWorker)\b", re.I), "navigator sensitive API"),
|
|
(re.compile(r"\b(?:fetch|XMLHttpRequest|WebSocket|EventSource)\s*\(", re.I), "network request"),
|
|
(re.compile(r"\bnavigator\s*\.\s*sendBeacon\s*\(", re.I), "network beacon"),
|
|
(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:
|
|
"""Return whether sensitive browser JS evaluation is explicitly allowed.
|
|
|
|
When true, ``browser_console(expression=...)`` runs without the
|
|
sensitive-primitive denylist even if ``browser.restrict_evaluate`` is set.
|
|
"""
|
|
_bt = _origin()
|
|
return _bt._browser_cfg(
|
|
"allow_unsafe_evaluate", False,
|
|
lambda v: _bt.is_truthy_value(v, default=False),
|
|
"browser.allow_unsafe_evaluate from config",
|
|
)
|
|
|
|
|
|
def _restrict_browser_evaluate() -> bool:
|
|
"""Return whether the sensitive-primitive eval denylist is enabled.
|
|
|
|
Off by default. ``browser_console(expression=...)`` is the agent's only
|
|
programmatic page-inspection path, and the denylist blocks the *names* of
|
|
common primitives (``fetch``, ``cookie``, ``querySelector(...input...)``)
|
|
rather than any actual exfiltration — which also blocks a large class of
|
|
legitimate DOM extraction (any selector or page script text containing
|
|
those words). Egress itself is still gated by the SSRF/private-URL guards
|
|
in ``_browser_eval`` regardless of this setting. Users who want the
|
|
strict vocabulary denylist (e.g. when browsing hostile pages with a
|
|
logged-in profile) opt in with ``browser.restrict_evaluate: true``;
|
|
``browser.allow_unsafe_evaluate: true`` overrides it back off.
|
|
"""
|
|
_bt = _origin()
|
|
return _bt._browser_cfg(
|
|
"restrict_evaluate", False,
|
|
lambda v: _bt.is_truthy_value(v, default=False),
|
|
"browser.restrict_evaluate from config",
|
|
)
|
|
|
|
|
|
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:
|
|
return None
|
|
for pattern, reason in _RISKY_BROWSER_EVAL_PATTERNS:
|
|
if pattern.search(expression):
|
|
return reason
|
|
return _sensitive_browser_eval_token_reason(expression)
|
|
|
|
|
|
def _enforce_browser_eval_policy(expression: str) -> Optional[str]:
|
|
"""Block sensitive browser JS evaluation when the opt-in denylist is on.
|
|
|
|
The denylist is opt-in (``browser.restrict_evaluate: true``) because it
|
|
gates on primitive *names*, which cripples legitimate DOM extraction —
|
|
see ``_restrict_browser_evaluate``. Network egress to private/internal
|
|
addresses is enforced separately in ``_browser_eval`` and does not depend
|
|
on this policy.
|
|
"""
|
|
_bt = _origin()
|
|
if not _bt._restrict_browser_evaluate():
|
|
return None
|
|
if _bt._allow_unsafe_browser_evaluate():
|
|
return None
|
|
reason = _risky_browser_eval_reason(expression)
|
|
if not reason:
|
|
return None
|
|
return (
|
|
"Blocked: browser_console(expression=...) tried to use sensitive browser "
|
|
f"JavaScript primitive ({reason}) while browser.restrict_evaluate is "
|
|
"enabled. Use browser_snapshot/browser_get_images/browser_console "
|
|
"without expression for normal inspection, or set "
|
|
"browser.restrict_evaluate: false in config.yaml to allow "
|
|
"programmatic evaluation."
|
|
)
|
|
|
|
|
|
def _camofox_current_page_private_url(tab_id: str, user_id: str) -> Optional[str]:
|
|
"""Return the Camofox page URL when it targets a private/internal address.
|
|
|
|
Camofox analogue of ``_current_page_private_url`` (evaluate endpoint instead
|
|
of the agent-browser CLI). Returns ``None`` when the page is public, the URL
|
|
can't be determined, or the probe errors (fail-open on probe failure,
|
|
matching the snapshot/vision guards — do not change to fail-closed without
|
|
also changing the sibling).
|
|
"""
|
|
_bt = _origin()
|
|
try:
|
|
from tools.browser_camofox import _post
|
|
|
|
data = _post(
|
|
f"/tabs/{tab_id}/evaluate",
|
|
body={"expression": "window.location.href", "userId": user_id},
|
|
)
|
|
current_url = str(data.get("result") if isinstance(data, dict) else data or "")
|
|
current_url = current_url.strip().strip('"').strip("'")
|
|
if current_url and (_bt._is_always_blocked_url(current_url) or not _bt._is_safe_url(current_url)):
|
|
return current_url
|
|
except Exception as exc:
|
|
_bt.logger.debug("_camofox_current_page_private_url: probe failed (%s)", exc)
|
|
return None
|