openvibers/openvibe.link Commits

Fence browser content and enforce task origins in Link (#156)

OP OpenVibers committed 2 days ago · aea1a1f04e · 9 files, +426 −11

* Fence browser page text and restrict task origins

* Fix bounded browser CI test on macOS

* Fence browser receipt text from screen actions

Changes

ddiff --git a/docs/computer.md b/docs/computer.md
iindex 7d06157..32b2d82 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -107,7 +107,7 @@ latch, never this computer.
| `probe` | `target`: `http(s)://…` (one GET, no redirect followed) or `tcp://host:port`; `timeout_s` | `status`, `content_type`, `etag`, `last_modified`, `body` (≤ 64 KB), `ms`; or `open`, `ms` |
| `screen` | `max_width` (default 1280) | `format` (`jpeg` with Pillow, else `png` ≤ 300 KB), `width`, `height`, `content_b64` |
| `input` | `action`: `move`, `click`, `double_click`, `right_click` (`x`, `y`), `scroll` (`dy` clicks, ±20), `type` (`text`, ≤ 2000), `key` (`keys`: one key or chord, `ctrl+s`, `Return`); `width`, `height`: the picture `x`, `y` are measured in | `done`, `action`, and the real `x`, `y` it used |
--| `browser` | `action`: `open` (`url`, http or https), `click` (`selector`, or the `text` a button or link shows), `fill` (`selector`, `text` ≤ 2000), `press` (`key`: Enter, Tab, Escape, Backspace, Delete, arrows, PageUp, PageDown, Home, End, Space), `back`, `read`, `screenshot` (`max_width`), `close` | a page: `url`, `title`, `text` (its visible text, ≤ 20,000 characters), `truncated`, `links` (the first 60); `screenshot`: `format`, `width`, `height`, `content_b64`; `fill`: `done`, `chars` |
++| `browser` | `action`: `open` (`url`, http or https), `click` (`selector`, or the `text` a button or link shows), `fill` (`selector`, `text` ≤ 2000), `press` (`key`: Enter, Tab, Escape, Backspace, Delete, arrows, PageUp, PageDown, Home, End, Space), `back`, `read`, `screenshot` (`max_width`), `close` | a page: `url`, `title`, `text` (visible text, capped at 20,000 serialized bytes), `truncated`, `links` (up to 60); `screenshot`: `format`, `width`, `height`, `content_b64`; `fill`: `done`, `chars` |
A program runs in its own process group with the Link account's rights, at most 2 at a time, and is killed at
`timeout_s` (the owner's `timeout_s` is the cap). A write with `overwrite` replaces the file atomically. Screens come
@@@ -156,6 +156,15 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
pass the check with one address and connect to another.
- **Nothing around it.** Loopback goes through the guard too, WebRTC sends no UDP around it, and QUIC is off.
- `browser_egress: "open"` turns the guard off, for a machine whose agent must reach a local service.
++- **Task origins.** A browser command may carry `origins`, a list of exact `http(s)://host[:port]` origins. When present,
++ the browser gets a proxy of its own: navigation, page resources, HTTPS tunnels and WebSocket connections to other
++ origins are refused, even if `browser_egress` is `open`. The list has at most 32 origins; an empty list permits no
++ network address. Shared desk hosts currently refuse an origin-bounded browser command because their sandbox has
++ different network plumbing. A task must send the same list on every command; omitting it is legacy unrestricted
++ access on a new browser.
++- **Page text boundary.** Page text and the lines reported by an action receipt carry a random nonce and the page's
++ origin in their markers. The source text is capped before those markers; `truncated` says when content was cut.
++ The complete page answer, including links and elements, is capped at 48 KiB of serialized JSON.
- **Elements and receipts** (Link v0.21.0, the Desks plan's step D3). `open` and `read` list the page's interactive
elements, one compact line each: `e7 button "Sign in"`, `e8 textbox "Email" (email)`, `e9 checkbox "Remember me"
checked`. That is at most 120 visible links, buttons, fields, boxes and menus, named by their label, text or
ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex a510ef9..e8c33c2 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -116,6 +116,7 @@ import urllib.parse
from openvibe_plugin import Deferred, Fault, Plugin
from .browser import KEY_CODES, Browser, BrowserError, find_binary
++from .origins import OriginError, allowed as origins_allow, parse_origins
from .curtain import (Curtain, EnvPrintDetector, Hotkey, PasswordFieldDetector, SecretFileDetector, SiteDetector,
_active_window, install_sigusr1)
from .desk import MAX_DESKS_CAP, BrowserDesk, Desks, PrivateDesk, default_max_desks
@@@ -1141,11 +1142,14 @@ class Computer(Plugin):
return dict(out, profile_saved=saved) if saved is not None else out
return self._act(cmd, level, stop, "stop %s (and close what runs on it)" % what, threaded=False)
-- def _desk_browser(self, desk, binary=None, env=None):
++ def _desk_browser(self, desk, binary=None, env=None, origins=None):
"""The agent's browser on a desk, started on first use: shown on a whole desk's display, headless on a browser
desk (its window the desk's size: its page is the desk's screen). Its own profile, downloads and temp folder."""
with self.browser_mu:
b = self.desk_browsers.get(desk)
++ if b is not None and b.alive() and origins is not None and b.allowed_origins != tuple(origins):
++ b.close() # a task may not inherit an earlier browser's unrestricted connections
++ b = None
if b is not None and b.alive():
self._set_downloads(b, desk)
return b
@@@ -1167,6 +1171,7 @@ class Computer(Plugin):
b.after_start = lambda: self._put_cookies(desk, b, kept)
b.download_dir = self._desk_downloads(desk)
b.allow_downloads = self.effective_level() == "full" and b.download_dir is not None
++ self._set_task_origins(b, origins)
with self.mu:
if self._generation_changed():
b.close()
@@@ -1954,6 +1959,22 @@ class Computer(Plugin):
return list(SANDBOX_CHROME_ARGS)
return [] if self.browser_egress == "open" else self.egress.args()
++ def _set_task_origins(self, browser, origins):
++ """Give a restricted browser its own proxy before Chrome starts. A shared-host sandbox has separate
++ network plumbing and refuses bounded tasks until it can enforce the same list inside its namespace."""
++ if origins is None:
++ return
++ if self._shared():
++ raise Fault("unsupported", "this shared desk cannot enforce a task origin list")
++ allowed = frozenset(origins)
++ proxy = EgressProxy(blocked=self._egress_blocked,
++ allow_origin=lambda scheme, host, port: (scheme, host.lower(), port) in allowed,
++ log=lambda *a: self.log(*a))
++ browser.extra_args = [arg for arg in browser.extra_args if not arg.startswith((
++ "--proxy-server=", "--proxy-bypass-list=", "--force-webrtc-ip-handling-policy="))] + proxy.args()
++ browser.task_egress = proxy
++ browser.allowed_origins = tuple(origins)
++
def _close_browser(self):
# Never waits for an action in flight: closing kills the process, and the action fails at once.
b, self.browser = self.browser, None
@@@ -1976,7 +1997,16 @@ class Computer(Plugin):
except ValueError:
return False
-- def _browser_url(self, raw, level):
++ @staticmethod
++ def _clean_origins(raw):
++ """The task's origin allowlist, or None when the task names none (legacy: unrestricted). An explicit
++ empty list allows nothing; anything else-shaped is refused, never guessed."""
++ try:
++ return parse_origins(raw)
++ except OriginError as e:
++ raise Fault("bad_value", str(e))
++
++ def _browser_url(self, raw, level, origins=None):
if not isinstance(raw, str) or not raw or len(raw) > MAX_URL or any(c in raw for c in "\x00\r\n"):
raise Fault("bad_value", "url is an http or https address")
u = urllib.parse.urlsplit(raw)
@@@ -1984,6 +2014,8 @@ class Computer(Plugin):
raise Fault("bad_value", "url is an http or https address")
if level != "full" and self._local_host(u.hostname.strip("[]")):
raise Fault("not_allowed", "%s is on this machine's own network: only at level full" % u.hostname)
++ if origins is not None and not origins_allow(raw, origins):
++ raise Fault("not_allowed", "%s is outside the task's origins" % u.hostname)
return raw
def _browser_op(self, v, level, env=None, desk=None):
@@@ -2002,8 +2034,13 @@ class Computer(Plugin):
if action == "profile_sign_out" and (not isinstance(v.get("site"), str) or not re.fullmatch(r"[a-z0-9.-]{1,253}", v["site"])):
raise Fault("bad_value", "Invalid site")
selector, text, key, url = v.get("selector"), v.get("text"), v.get("key"), None
++ # The task's origins, when it names them: navigation outside is refused (open and link clicks up
++ # front; anything else that lands outside is flagged on the result). Absent means legacy unrestricted.
++ if "origins" in v and v["origins"] is None:
++ raise Fault("bad_value", "origins is a list, not null")
++ origins = self._clean_origins(v.get("origins"))
if action == "open":
-- url = self._browser_url(v.get("url"), level)
++ url = self._browser_url(v.get("url"), level, origins)
if action in ("click", "fill") and selector is not None and (not isinstance(selector, str) or not selector or len(selector) > MAX_SELECTOR):
raise Fault("bad_value", "selector is a CSS selector of at most %d characters" % MAX_SELECTOR)
if action == "click" and selector is None and (not isinstance(text, str) or not text.strip() or len(text) > 200):
@@@ -2028,10 +2065,13 @@ class Computer(Plugin):
def browser():
if env: # on a desk: its own browser (shown on a whole desk, headless on a browser desk)
-- return self._desk_browser(desk, binary, env)
++ return self._desk_browser(desk, binary, env, origins)
with self.browser_mu:
++ if self.browser is not None and self.browser.alive() and origins is not None and self.browser.allowed_origins != tuple(origins):
++ self._close_browser()
if self.browser is None or not self.browser.alive():
b = self.browser_factory(binary, self.browser_profile, self.browser_headless)
++ self._set_task_origins(b, origins)
b.allow_downloads = self.effective_level() == "full"
with self.mu:
if self._generation_changed():
@@@ -2053,6 +2093,10 @@ class Computer(Plugin):
b = browser()
try:
with lock(): # one action at a time in each browser; two desks' browsers act in parallel
++ if origins is not None and action == "click":
++ href = b.click_href(selector=selector, text=None if selector else text)
++ if href and not origins_allow(href, origins):
++ raise Fault("not_allowed", "the link is outside the task's origins")
if action == "open":
out = b.open(url)
elif action == "read":
@@@ -2087,6 +2131,9 @@ class Computer(Plugin):
out = b.back()
if isinstance(out, dict):
self._desk_stream_saw_url(out.get("url"))
++ current = out.get("url")
++ if origins is not None and isinstance(current, str) and current not in ("", "about:blank") and not origins_allow(current, origins):
++ raise Fault("not_allowed", "the browser left the task's origins")
return out
except BrowserError as e:
raise Fault(e.code, e.message)
ddiff --git a/plugins/computer/openvibe_computer/browser.py b/plugins/computer/openvibe_computer/browser.py
iindex a3162c0..c3408d6 100644
---- a/plugins/computer/openvibe_computer/browser.py
++++ b/plugins/computer/openvibe_computer/browser.py
@@@ -24,11 +24,31 @@ import sys
import threading
import time
++from openvibe_computer.fence import cap_text, fence as fence_text, parse_fenced
++from openvibe_computer.origins import origin_of
++
MAX_TEXT = 20000
++MAX_PAGE_BYTES = 48 * 1024
STARTUP_S = 90.0 # a first start in a cold sandbox (CI runners took over 45 s on 2026-11-02)
MAX_LINKS = 60
MAX_ELEMENTS = 120
MAX_APPEARED = 8
++
++
++def cap_json_text(value, max_bytes):
++ """Bound both source UTF-8 and the JSON bytes sent through the pipe (non-ASCII escapes can be 6x)."""
++ text, cut = cap_text(value, max_bytes)
++ if len(json.dumps(text).encode("utf-8")) <= max_bytes:
++ return text, cut
++ mark = "…[truncated]"
++ lo, hi = 0, len(text)
++ while lo < hi:
++ mid = (lo + hi + 1) // 2
++ if len(json.dumps(text[:mid] + mark).encode("utf-8")) <= max_bytes:
++ lo = mid
++ else:
++ hi = mid - 1
++ return text[:lo] + mark, True
MAX_FOLLOWED = 5
MAX_SESSION_COOKIES = 3000
EXACT_HTTPS_ORIGIN = re.compile(r"^https://[a-z0-9.-]+(:[0-9]{1,5})?$")
@@@ -174,6 +194,23 @@ FOCUS_JS = """((selector) => {
return true;
})(%s)"""
++# What a click would follow, read before it is clicked (the origins gate): the same match as CLICK_JS,
++# then the nearest link's address, or null when the target is no link. Read-only: nothing is clicked.
++TARGET_JS = """((selector, text) => {
++ let el = null;
++ if (selector) el = document.querySelector(selector);
++ else {
++ const want = text.trim().toLowerCase();
++ const items = [...document.querySelectorAll('a, button, [role=button], input[type=submit], input[type=button], summary, label')];
++ el = items.find((e) => (e.innerText || e.value || e.getAttribute('aria-label') || '').trim().toLowerCase() === want)
++ || items.find((e) => (e.innerText || e.value || e.getAttribute('aria-label') || '').trim().toLowerCase().includes(want));
++ }
++ if (!el) return null;
++ const link = el.closest ? el.closest('a[href]') : null;
++ const href = (link && link.href) || null;
++ return href && /^https?:/.test(href) ? href : null;
++})(%s, %s)"""
++
class BrowserError(Exception):
def __init__(self, code, message):
@@@ -235,6 +272,8 @@ class Browser:
self.follow_mu = threading.Lock()
self.storage_origins = set()
self.after_start = None # called once the browser runs (a kept profile's session cookies go back in)
++ self.allowed_origins = None # fixed for this browser process; a new task gets a new proxy and process
++ self.task_egress = None
# ---- the process and the pipe ------------------------------------------------------------------------------------
def alive(self):
@@@ -491,7 +530,49 @@ class Browser:
def summary(self):
self.used_at = time.monotonic()
self._follow()
-- return self._eval(SUMMARY_JS)
++ page = self._eval(SUMMARY_JS)
++ # The page is untrusted. Cap UTF-8 bytes after JS's character cap, then put the nonce markers outside
++ # that cap. A multi-byte page can exceed MAX_TEXT bytes even when it has MAX_TEXT characters.
++ if isinstance(page, dict) and isinstance(page.get("text"), str):
++ capped, cut = cap_json_text(page["text"], MAX_TEXT)
++ title, title_cut = cap_json_text(page.get("title", ""), 512)
++ url, url_cut = cap_json_text(page.get("url", ""), 2048)
++ budget = MAX_PAGE_BYTES - len(json.dumps(capped).encode("utf-8")) - len(json.dumps(title).encode("utf-8")) - len(json.dumps(url).encode("utf-8")) - 2048
++ links, elements = [], []
++ for link in page.get("links") or []:
++ if not isinstance(link, dict):
++ cut = True
++ continue
++ label, label_cut = cap_json_text(link.get("text", ""), 160)
++ href, href_cut = cap_json_text(link.get("href", ""), 2048)
++ size = len(json.dumps({"text": label, "href": href}).encode("utf-8"))
++ if size > budget:
++ cut = True
++ break
++ links.append({"text": label, "href": href})
++ budget -= size
++ cut = cut or label_cut or href_cut
++ for element in page.get("elements") or []:
++ line, line_cut = cap_json_text(element, 256)
++ size = len(json.dumps(line).encode("utf-8")) + 8
++ if size > budget:
++ cut = True
++ break
++ elements.append(line)
++ budget -= size
++ cut = cut or line_cut
++ parts = origin_of(page.get("url"))
++ if parts:
++ scheme, host, port = parts
++ origin = "%s://%s:%d" % (scheme, "[%s]" % host if ":" in host else host, port)
++ else:
++ origin = "unknown"
++ if not re.fullmatch(r"https?://[A-Za-z0-9.:[\]-]+", origin):
++ origin = "unknown"
++ fenced, nonce = fence_text(capped, origin=origin)
++ page = dict(page, url=url, title=title, text=fenced, links=links, elements=elements,
++ fence=nonce, origin=origin, truncated=bool(page.get("truncated") or cut or title_cut or url_cut))
++ return page
def open(self, url):
self.start()
@@@ -589,13 +670,25 @@ class Browser:
def _with_receipt(self, before):
after = self.summary()
-- r = receipt(before, after)
++ # Compare the page's body, not its new fence markers; otherwise every action appears to add two lines.
++ plain = dict(after, text=parse_fenced(after.get("text")) or "")
++ r = receipt(before, plain)
if r is not None:
if self.tab_change:
r["page"] = self.tab_change # a page it opened is now in front ("opened"), or that page closed ("closed")
++ r["appeared"] = [fence_text(line, after.get("fence"), after.get("origin", "unknown"))[0] for line in r["appeared"]]
after["receipt"] = r
return after
++ def click_href(self, selector=None, text=None):
++ """The http(s) address a click would follow, or None (no match, a button, a script link). Read-only."""
++ self._need()
++ try:
++ href = self._eval(TARGET_JS % (json.dumps(selector), json.dumps(text or "")))
++ except BrowserError:
++ return None
++ return href if isinstance(href, str) and href else None
++
def click(self, selector=None, text=None):
self._need()
before = self._before()
@@@ -736,12 +829,18 @@ class Browser:
out = {"done": True}
try:
self._follow()
-- r = receipt(before, self._eval(BEFORE_JS))
++ after = self._eval(BEFORE_JS)
++ r = receipt(before, after)
except BrowserError:
r = None
if r is not None:
if self.tab_change:
r["page"] = self.tab_change
++ parts = origin_of(after.get("url"))
++ origin = "%s://%s:%d" % (parts[0], "[%s]" % parts[1] if ":" in parts[1] else parts[1], parts[2]) if parts else "unknown"
++ if not re.fullmatch(r"https?://[A-Za-z0-9.:[\]-]+", origin):
++ origin = "unknown"
++ r["appeared"] = [fence_text(line, origin=origin)[0] for line in r["appeared"]]
out["receipt"] = r
return out
@@@ -872,4 +971,7 @@ class Browser:
proc.stderr.close()
except OSError:
pass
++ if self.task_egress is not None:
++ self.task_egress.stop()
++ self.task_egress = None
return {"done": True, "closed": proc is not None}
ddiff --git a/plugins/computer/openvibe_computer/egress.py b/plugins/computer/openvibe_computer/egress.py
iindex cd3751e..e048380 100644
---- a/plugins/computer/openvibe_computer/egress.py
++++ b/plugins/computer/openvibe_computer/egress.py
@@@ -38,8 +38,9 @@ def not_public(ip):
class EgressProxy:
-- def __init__(self, blocked, resolve=None, log=None):
++ def __init__(self, blocked, resolve=None, log=None, allow_origin=None):
self.blocked = blocked # (ip, port) -> True to refuse
++ self.allow_origin = allow_origin # (scheme, host, port) -> True for this browser task
self.resolve = resolve or (lambda host, port: [ai[4][0] for ai in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)])
self.log = log or (lambda *a: None)
self.sock = None
@@@ -110,6 +111,12 @@ class EgressProxy:
host, port = self._split(hostport, 80)
if host is None:
return self._answer(client, 400, "Bad Request")
++ scheme = "https" if method == "CONNECT" else "http"
++ if self.allow_origin is not None and not self.allow_origin(scheme, host, port):
++ with self.mu:
++ self.refused += 1
++ self.log("egress refused outside task origins %s:%d", host, port)
++ return self._answer(client, 403, "Outside the task's origins")
upstream = self._connect(host, port)
if upstream is None:
return self._answer(client, 403, "Forbidden by the agent's network guard")
ddiff --git a/plugins/computer/openvibe_computer/fence.py b/plugins/computer/openvibe_computer/fence.py
nnew file mode 100644
iindex 0000000..abf7612
---- /dev/null
++++ b/plugins/computer/openvibe_computer/fence.py
@@@ -0,0 +1,68 @@
++"""Nonce-fenced untrusted text, and byte caps with the cut marked.
++
++A page an agent reads can carry instructions for it (prompt injection). The fence tells the two apart: the
++machine mints a fresh 128-bit nonce per result, after the text is fixed, and wraps the text in markers bearing
++it. Injected text predates the nonce, so it cannot bear it except by guessing 128 bits; an agent treats only
++text inside a fence whose two nonces match as the page's, and everything else as untrusted.
++
++ <<<ov-text nonce:{hex} origin:{origin}>>>
++ ...the page's text...
++ <<<ov-text-end nonce:{hex}>>>
++
++Long text is cut first and the cut is marked, so a fence never hides a truncation: the marker sits outside the
++capped region. Binary answers (screenshots, file content) are never fenced and never capped here.
++"""
++
++import secrets
++import re
++
++NONCE_BYTES = 16
++OPEN_FMT = "<<<ov-text nonce:%s origin:%s>>>"
++CLOSE_FMT = "<<<ov-text-end nonce:%s>>>"
++START = re.compile(r"^<<<ov-text nonce:([0-9a-f]{32}) origin:(?:https?://[A-Za-z0-9.:[\]-]+|unknown)>>>$")
++END = re.compile(r"^<<<ov-text-end nonce:([0-9a-f]{32})>>>$")
++
++
++def mint_nonce():
++ """A fresh fence nonce: 128 random bits, hex."""
++ return secrets.token_hex(NONCE_BYTES)
++
++
++def fence(text, nonce=None, origin="unknown"):
++ """Wrap text in a fresh (or given) nonce fence. Returns (fenced, nonce)."""
++ nonce = nonce or mint_nonce()
++ body = text if isinstance(text, str) else str(text or "")
++ return "%s\n%s\n%s" % (OPEN_FMT % (nonce, origin), body, CLOSE_FMT % nonce), nonce
++
++
++def parse_fenced(text):
++ """The page text inside a valid fence, or None. Both markers must bear the same nonce."""
++ if not isinstance(text, str):
++ return None
++ lines = text.split("\n")
++ if len(lines) < 3:
++ return None
++ first, last = START.fullmatch(lines[0]), END.fullmatch(lines[-1])
++ if first is None or last is None or first.group(1) != last.group(1):
++ return None
++ return "\n".join(lines[1:-1])
++
++
++def cap_text(text, max_bytes):
++ """Cut text at max_bytes of UTF-8 with the cut marked, never splitting a character. Short text comes
++ back untouched and unfenced; the marker sits outside the capped region."""
++ if not isinstance(text, str):
++ text = str(text or "")
++ raw = text.encode("utf-8")
++ if len(raw) <= max_bytes:
++ return text, False
++ cut = raw[:max_bytes]
++ while cut:
++ try:
++ body = cut.decode("utf-8")
++ break
++ except UnicodeDecodeError:
++ cut = cut[:-1]
++ else:
++ body = ""
++ return body + "\n…[truncated %d of %d bytes]" % (len(raw) - len(cut), len(raw)), True
ddiff --git a/plugins/computer/openvibe_computer/origins.py b/plugins/computer/openvibe_computer/origins.py
nnew file mode 100644
iindex 0000000..6e6b296
---- /dev/null
++++ b/plugins/computer/openvibe_computer/origins.py
@@@ -0,0 +1,79 @@
++"""Per-task origin allowlists for the browser: navigation outside the task's origins is refused.
++
++An origin is scheme + host + port (``https://example.com``, default ports filled in). The task names its list;
++absent means legacy unrestricted. An explicit empty list allows nothing. Anything else-shaped is refused rather
++than guessed: an allowlist that cannot be read is not an allowlist.
++"""
++
++import urllib.parse
++
++DEFAULT_PORTS = {"http": 80, "https": 443}
++
++
++class OriginError(ValueError):
++ """An origins list, or a URL against it, that cannot be honored."""
++
++
++def split_origin(value):
++ """(scheme, host, port) for an http(s) origin, or None. No path, query, userinfo or fragment allowed."""
++ if not isinstance(value, str):
++ return None
++ try:
++ u = urllib.parse.urlsplit(value.strip())
++ except ValueError:
++ return None
++ if u.scheme not in ("http", "https") or not u.hostname:
++ return None
++ if u.path not in ("", "/") or u.query or u.fragment or u.username or u.password:
++ return None
++ try:
++ port = u.port or DEFAULT_PORTS[u.scheme]
++ except ValueError:
++ return None
++ return (u.scheme, u.hostname.lower(), port)
++
++
++def parse_origins(raw):
++ """The task's allowlist, or None when the task names none (legacy: unrestricted). An explicit empty
++ list allows nothing. Anything else-shaped raises OriginError: fail closed, never guessed."""
++ if raw is None:
++ return None
++ if not isinstance(raw, list):
++ raise OriginError("origins is a list of http(s) origins")
++ if len(raw) > 32:
++ raise OriginError("origins has at most 32 addresses")
++ origins = []
++ for item in raw:
++ if not isinstance(item, str) or len(item) > 2048:
++ raise OriginError("each origin is an http(s) address of at most 2048 characters")
++ origin = split_origin(item)
++ if origin is None:
++ raise OriginError("origins holds http(s) origins like https://example.com, got %r" % (item,))
++ origins.append(origin)
++ return origins
++
++
++def origin_of(url):
++ """(scheme, host, port) for an http(s) URL of any path, or None when it has none (about:blank, data:, …)."""
++ if not isinstance(url, str):
++ return None
++ try:
++ u = urllib.parse.urlsplit(url.strip())
++ except ValueError:
++ return None
++ if u.scheme not in ("http", "https") or not u.hostname:
++ return None
++ try:
++ port = u.port or DEFAULT_PORTS[u.scheme]
++ except ValueError:
++ return None
++ return (u.scheme, u.hostname.lower(), port)
++
++
++def allowed(url, origins):
++ """Whether the URL's origin is in the allowlist. None origins allow everything (legacy); otherwise the
++ URL must parse to an allowed origin, and an unparseable URL is never allowed."""
++ if origins is None:
++ return True
++ origin = origin_of(url)
++ return origin is not None and origin in set(origins)
ddiff --git a/plugins/computer/tests/test_browser.py b/plugins/computer/tests/test_browser.py
iindex d89bfd8..9ddb72b 100644
---- a/plugins/computer/tests/test_browser.py
++++ b/plugins/computer/tests/test_browser.py
@@@ -436,7 +436,9 @@ class RealBrowserTest(unittest.TestCase):
self.assertTrue(any(" checkbox " in l and l.endswith(" checked") for l in app["elements"]), "a checkbox says it is checked")
self.assertFalse(any("Hidden" in l for l in app["elements"]), "a hidden element is not offered")
done = b.click(selector='[data-ov-ref="%s"]' % refs["Add a row"])
-- self.assertEqual(done["receipt"], {"url_changed": False, "appeared": ["Row added"], "gone": 1})
++ from openvibe_computer.fence import parse_fenced
++ self.assertEqual({**done["receipt"], "appeared": [parse_fenced(x) for x in done["receipt"]["appeared"]]},
++ {"url_changed": False, "appeared": ["Row added"], "gone": 1})
again = b.read()
self.assertIn('%s button "Add a row"' % refs["Add a row"], again["elements"], "the same element keeps its ref")
away = b.click(selector='[data-ov-ref="%s"]' % refs["Second page"])
@@@ -518,7 +520,8 @@ class RealBrowserTest(unittest.TestCase):
r = b._eval("(() => { const r = document.getElementById('go').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; })()")
x, y = round(r[0] * shot["width"] / 1000), round(r[1] * shot["height"] / 600)
clicked = cmd("input", {"action": "click", "x": x, "y": y, "width": shot["width"], "height": shot["height"]})
-- self.assertEqual(clicked["receipt"]["appeared"], ["Row added"], clicked)
++ from openvibe_computer.fence import parse_fenced
++ self.assertEqual([parse_fenced(x) for x in clicked["receipt"]["appeared"]], ["Row added"], clicked)
# Type into the field it focused, select all, delete.
b._eval("document.querySelector('input[type=email]').focus()")
cmd("input", {"action": "type", "text": "[email protected]"})
ddiff --git a/plugins/computer/tests/test_egress.py b/plugins/computer/tests/test_egress.py
iindex efa0a31..29a0a4a 100644
---- a/plugins/computer/tests/test_egress.py
++++ b/plugins/computer/tests/test_egress.py
@@@ -20,6 +20,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(HERE)), "sdk"))
from openvibe_computer import Computer # noqa: E402
from openvibe_computer.browser import Browser, find_binary # noqa: E402
from openvibe_computer.egress import EgressProxy, not_public # noqa: E402
++from openvibe_computer.origins import parse_origins # noqa: E402
from openvibe_plugin import Fault, Runtime # noqa: E402
from test_computer import Out # noqa: E402
@@@ -107,6 +108,20 @@ class EgressProxyTest(unittest.TestCase):
s.close()
self.assertTrue(data.startswith(b"HTTP/1.1 403"), data)
++ def test_task_origins_guard_http_and_tunnels_before_connect(self):
++ ok_port = self.ok.server_address[1]
++ proxy = EgressProxy(blocked=lambda ip, port: False,
++ allow_origin=lambda scheme, host, port: (scheme, host, port) == ("http", "127.0.0.1", ok_port))
++ port = proxy.start()
++ self.addCleanup(proxy.stop)
++ for method, target, expected in ((b"GET", b"http://127.0.0.1:%d/" % ok_port, b"200"),
++ (b"GET", b"http://127.0.0.1:%d/" % self.no_port, b"403"),
++ (b"CONNECT", b"127.0.0.1:%d" % ok_port, b"403")):
++ s, data = ask(port, method + b" " + target + b" HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
++ s.close()
++ self.assertIn(expected, data.split(b"\r\n", 1)[0])
++ self.assertEqual(proxy.refused, 2)
++
def test_a_name_with_any_refused_address_is_refused(self):
# One public and one private address: the name is refused, so no answer can pick the private one later.
proxy = EgressProxy(blocked=lambda ip, port: not_public(ip), resolve=lambda host, port: ["93.184.215.14", "192.168.1.1"])
@@@ -149,9 +164,44 @@ class PluginEgressTest(unittest.TestCase):
with self.assertRaises(Fault):
plugin._configure({"level": "trusted", "browser_egress": "sometimes"})
++ def test_task_proxy_overrides_open_egress_before_the_browser_starts(self):
++ plugin, _ = self.boot(browser_egress="open")
++ b = Browser("chromium", "/nonexistent", extra_args=[])
++ self.addCleanup(b.close)
++ plugin._set_task_origins(b, parse_origins(["https://example.org"]))
++ self.assertEqual(b.allowed_origins, (("https", "example.org", 443),))
++ self.assertTrue(b.extra_args[0].startswith("--proxy-server="))
++ s, head = ask(b.task_egress.port, b"CONNECT other.example.org:443 HTTP/1.1\r\nHost: other.example.org\r\n\r\n")
++ s.close()
++ self.assertTrue(head.startswith(b"HTTP/1.1 403"), head)
++
@unittest.skipUnless(find_binary(shutil.which, sys.platform), "no Chromium-family browser on this machine")
class RealEgressTest(unittest.TestCase):
++ def test_a_bounded_task_blocks_other_origins_even_with_open_egress(self):
++ ok, no = serve(), serve()
++ self.addCleanup(ok.shutdown)
++ self.addCleanup(no.shutdown)
++ profile = tempfile.mkdtemp(prefix="ov-origins-")
++ self.addCleanup(lambda: shutil.rmtree(profile, ignore_errors=True))
++ plugin = Computer(env={}, platform=sys.platform, which=shutil.which)
++ rt = Runtime(plugin, infile=io.StringIO(""), outfile=Out())
++ rt.dispatch({"op": "hello", "config": {"level": "full", "roots": [tempfile.gettempdir()], "notify": False,
++ "browser_egress": "open", "browser_profile": profile}})
++ plugin.browser_factory = lambda binary, path, headless: Browser(
++ binary, path, headless, extra_args=["--no-sandbox"] if os.environ.get("CI") else [])
++ self.addCleanup(plugin._close_browser)
++ self.addCleanup(plugin.egress.stop)
++ allowed_url = "http://127.0.0.1:%d/" % ok.server_address[1]
++ blocked_url = "http://127.0.0.1:%d/" % no.server_address[1]
++ origins = ["http://127.0.0.1:%d" % ok.server_address[1]]
++ run, _, _ = plugin._browser_op({"action": "open", "url": allowed_url, "origins": origins}, "full")
++ self.assertEqual(run()["title"], "Here")
++ with self.assertRaises(Fault):
++ plugin._browser_op({"action": "open", "url": blocked_url, "origins": origins}, "full")
++ got = plugin.browser._eval("fetch(%r).then((r) => 'reached ' + r.status).catch(() => 'refused')" % blocked_url)
++ self.assertEqual(got, "refused", "a page script cannot leave the task's origins")
++
def test_a_pages_script_cannot_reach_a_refused_address(self):
ok, no = serve(), serve()
self.addCleanup(ok.shutdown)
ddiff --git a/plugins/computer/tests/test_origin_fence.py b/plugins/computer/tests/test_origin_fence.py
nnew file mode 100644
iindex 0000000..15b7ed1
---- /dev/null
++++ b/plugins/computer/tests/test_origin_fence.py
@@@ -0,0 +1,50 @@
++"""A task's origin list and page-content boundary fail closed on malformed input."""
++import os
++import sys
++import unittest
++
++sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
++sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "sdk"))
++
++from openvibe_computer.fence import cap_text, fence, parse_fenced
++from openvibe_computer.origins import OriginError, allowed, parse_origins
++from openvibe_computer.browser import Browser, MAX_PAGE_BYTES
++import json
++
++
++class OriginFenceTest(unittest.TestCase):
++ def test_exact_origins_and_bad_lists(self):
++ origins = parse_origins(["https://Example.org", "http://localhost:8080"])
++ self.assertTrue(allowed("https://example.org/account", origins))
++ self.assertFalse(allowed("http://example.org/", origins))
++ self.assertFalse(allowed("https://other.example.org/", origins))
++ self.assertFalse(allowed("http://localhost:8081/", origins))
++ self.assertFalse(allowed("https://example.org/", parse_origins([])))
++ for bad in ("https://example.org", ["https://example.org/path"], ["https://[email protected]"], ["file:///etc/passwd"]):
++ with self.assertRaises(OriginError):
++ parse_origins(bad)
++
++ def test_nonce_fence_and_utf8_byte_cap(self):
++ clipped, truncated = cap_text("ééé", 5)
++ self.assertTrue(truncated)
++ self.assertTrue(clipped.startswith("éé\n"))
++ body, nonce = fence(clipped, origin="https://example.org:443")
++ self.assertEqual(parse_fenced(body), clipped)
++ self.assertIsNone(parse_fenced(body.replace(nonce, "0" * 32, 1)))
++ self.assertIsNone(parse_fenced("<<<ov-text nonce:bad origin:https://example.org>>>\nattack\n<<<ov-text-end nonce:bad>>>"))
++
++ def test_page_answer_caps_serialized_bytes_and_marks_the_cut(self):
++ b = Browser("chromium", "/nonexistent")
++ b._follow = lambda: None
++ b._eval = lambda _script: {"url": "https://example.org/", "title": "é" * 5000,
++ "text": "é" * 20000, "truncated": False,
++ "links": [{"text": "é" * 100, "href": "https://example.org/" + "x" * 3000}] * 60,
++ "elements": ["e1 button " + "é" * 300] * 120}
++ page = b.summary()
++ self.assertTrue(page["truncated"])
++ self.assertLessEqual(len(json.dumps(page).encode()), MAX_PAGE_BYTES)
++ self.assertIn("truncated", parse_fenced(page["text"]))
++
++
++if __name__ == "__main__":
++ unittest.main()