Problem
A daemon navigate request with a javascript: URL executes script even when the action policy explicitly denies evaluate.
This is separate from action-category naming: the reproduction uses the actual daemon action evaluate, verifies that normal eval is denied, then observes a script side effect through navigation. The navigation itself reports net::ERR_ABORTED, although the script already changed the page.
Reproduced with the official v0.39.1 macOS arm64 binary and Chrome. The same authorization path remains on current main at 5ee8691339a5a002e4d7454eec95250253bc7419.
Reproduction
Python 3, installed Chrome, and agent-browser on PATH are required. This uses only a local HTTP fixture and an isolated session. Save as repro.py and run python3 repro.py:
import http.server
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import tempfile
import threading
binary = shutil.which("agent-browser")
assert binary, "Put agent-browser on PATH first"
root = Path(tempfile.mkdtemp(prefix="ab-policy-", dir="/tmp"))
policy = root / "policy.json"
policy.write_text(json.dumps({"default": "allow", "deny": ["evaluate"]}))
env = {**os.environ, "AGENT_BROWSER_SOCKET_DIR": str(root)}
command = [binary, "--session", "repro", "--action-policy", str(policy), "--json"]
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"<title>BASELINE</title><h1>Local fixture</h1>")
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
def run(arguments):
result = subprocess.run(command + arguments, env=env,
capture_output=True, text=True, timeout=25)
print(result.stdout.strip())
return json.loads(result.stdout)
def send_navigation():
request = {"id": "script-navigation", "action": "navigate",
"url": "javascript:document.title='NAVIGATION_EXECUTED';void(0)"}
with socket.socket(socket.AF_UNIX) as connection:
connection.settimeout(10)
connection.connect(str(root / "repro.sock"))
connection.sendall((json.dumps(request) + "\n").encode())
response = json.loads(connection.makefile().readline())
print(json.dumps(response))
return response
try:
assert run(["open", f"http://127.0.0.1:{server.server_port}/"])["success"]
denied = run(["eval", "document.title='EVAL_ALLOWED'"])
assert not denied["success"] and "denied by policy" in denied["error"]
send_navigation()
assert run(["get", "title"])["data"]["title"] == "NAVIGATION_EXECUTED"
print("REPRODUCED: navigate executed script while evaluate was denied")
policy.write_text(json.dumps({"default": "allow",
"deny": ["evaluate", "navigate", "tab_new"]}))
blocked = send_navigation()
assert not blocked["success"] and "denied by policy" in blocked["error"]
print("VERIFIED MITIGATION: denying navigation blocks this request")
finally:
closed = run(["close"])
assert closed["success"]
server.shutdown()
server.server_close()
worker.join(timeout=3)Key results:
Policy: {"default":"allow","deny":["evaluate"]}
eval: Action 'evaluate' is denied by policy
navigate javascript:...: Navigation failed: net::ERR_ABORTED
get title: NAVIGATION_EXECUTEDExpected: a navigation whose effect is executing JavaScript should also require permission for script execution, before sending the request to Chrome.
The bypass is reproduced through the daemon's JSON-line socket protocol. The CLI normalizes this particular open javascript:... spelling into an invalid navigation URL, so I am not claiming that the identical CLI spelling works. The daemon is the authorization boundary exercised here.
Root cause
policy_actions_for_command checks the navigate action without adding the script-execution action for script URLs. The same function already adds extra actions for compound operations such as a11y <url>, so there is an existing place to account for the additional effect.
Proposed fix
Before any navigation side effect, parse the URL and require the evaluate policy action when the scheme is javascript. Apply the check to other URL-taking commands that can execute script, including tab_new where applicable. Use URL parsing rather than a case-sensitive string prefix so normalization and mixed-case schemes do not evade the check. Both deny and confirmation rules should apply before dispatch.
Our embedded integration already adds an extra script-policy action for these URL schemes. Upstream should use its real evaluate action name; copying a differently named managed-policy label would repeat the category mismatch in #1612.
Workaround (verified)
Temporarily deny navigate and tab_new as well as evaluate. The reproduction updates the same policy file and verifies that the exact navigation request is then denied by policy. This prevents normal navigation too, so it is a restrictive mitigation rather than equivalent behavior.
Alternatively, a host can reject script-scheme URLs before forwarding daemon requests. That validation must cover every applicable URL-taking path and is not a replacement for daemon-side enforcement.
Validation and regression coverage
The full reproduction and restrictive workaround above were run against v0.39.1. Current main was inspected, not built. No OS sandbox escape or cross-user access is claimed; the demonstrated bug is inconsistent action-policy enforcement on the daemon protocol.
Regression tests should verify:
- Explicit
evaluatedeny blocks the request before the page title changes. - Script-execution confirmation applies to script navigation too.
- Mixed-case schemes and URL-parser whitespace normalization.
- The relevant new-tab path.
- Ordinary HTTP navigation remains allowed when the policy permits it.
Related: #1612 concerns documented action categories versus exact matching. This reproduction denies evaluate correctly and still executes script via a different action, so fixing category names alone does not resolve it.
Activity
0No activity yet.