openvibers/openvibe.link Commits
Fence browser content and enforce task origins in Link (#156)
Changes
@@@ -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 `kpass 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 interactiveelements, 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@@@ -116,6 +116,7 @@ import urllib.parsefrom openvibe_plugin import Deferred, Fault, Pluginfrom .browser import KEY_CODES, Browser, BrowserError, find_binary++from .origins import OriginError, allowed as origins_allow, parse_originsfrom .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 outreturn 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 browserdesk (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 = Noneif 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 rawdef _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 outexcept BrowserError as e:raise Fault(e.code, e.message)@@@ -24,11 +24,31 @@ import sysimport threadingimport 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 * 1024STARTUP_S = 90.0 # a first start in a cold sandbox (CI runners took over 45 s on 2026-11-02)MAX_LINKS = 60MAX_ELEMENTS = 120MAX_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, TrueMAX_FOLLOWED = 5MAX_SESSION_COOKIES = 3000EXACT_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 pagedef 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"] = rreturn 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 = Noneif 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"] = rreturn 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 = Nonereturn {"done": True, "closed": proc is not None}@@@ -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 taskself.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")nnew file mode 100644@@@ -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)), Truennew file mode 100644@@@ -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)@@@ -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]"})@@@ -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: E402from openvibe_computer.browser import Browser, find_binary # noqa: E402from openvibe_computer.egress import EgressProxy, not_public # noqa: E402++from openvibe_computer.origins import parse_origins # noqa: E402from openvibe_plugin import Fault, Runtime # noqa: E402from 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)nnew file mode 100644@@@ -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()
@@ -107,7 +107,7 @@ latch, never this computer.
@@ -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` |
| `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` |
| `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 |
| `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
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
`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
@@ -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.
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.
- **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.
- `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 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"
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
checked`. That is at most 120 visible links, buttons, fields, boxes and menus, named by their label, text or
@@ -116,6 +116,7 @@ import urllib.parse
@@ -116,6 +116,7 @@ import urllib.parse
from openvibe_plugin import Deferred, Fault, Plugin
from openvibe_plugin import Deferred, Fault, Plugin
from .browser import KEY_CODES, Browser, BrowserError, find_binary
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,
from .curtain import (Curtain, EnvPrintDetector, Hotkey, PasswordFieldDetector, SecretFileDetector, SiteDetector,
_active_window, install_sigusr1)
_active_window, install_sigusr1)
from .desk import MAX_DESKS_CAP, BrowserDesk, Desks, PrivateDesk, default_max_desks
from .desk import MAX_DESKS_CAP, BrowserDesk, Desks, PrivateDesk, default_max_desks
@@ -1141,11 +1142,14 @@ class Computer(Plugin):
@@ -1141,11 +1142,14 @@ class Computer(Plugin):
return dict(out, profile_saved=saved) if saved is not None else out
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)
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
"""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."""
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:
with self.browser_mu:
b = self.desk_browsers.get(desk)
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():
if b is not None and b.alive():
self._set_downloads(b, desk)
self._set_downloads(b, desk)
return b
return b
@@ -1167,6 +1171,7 @@ class Computer(Plugin):
@@ -1167,6 +1171,7 @@ class Computer(Plugin):
b.after_start = lambda: self._put_cookies(desk, b, kept)
b.after_start = lambda: self._put_cookies(desk, b, kept)
b.download_dir = self._desk_downloads(desk)
b.download_dir = self._desk_downloads(desk)
b.allow_downloads = self.effective_level() == "full" and b.download_dir is not None
b.allow_downloads = self.effective_level() == "full" and b.download_dir is not None
+ self._set_task_origins(b, origins)
with self.mu:
with self.mu:
if self._generation_changed():
if self._generation_changed():
b.close()
b.close()
@@ -1954,6 +1959,22 @@ class Computer(Plugin):
@@ -1954,6 +1959,22 @@ class Computer(Plugin):
return list(SANDBOX_CHROME_ARGS)
return list(SANDBOX_CHROME_ARGS)
return [] if self.browser_egress == "open" else self.egress.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):
def _close_browser(self):
# Never waits for an action in flight: closing kills the process, and the action fails at once.
# Never waits for an action in flight: closing kills the process, and the action fails at once.
b, self.browser = self.browser, None
b, self.browser = self.browser, None
@@ -1976,7 +1997,16 @@ class Computer(Plugin):
@@ -1976,7 +1997,16 @@ class Computer(Plugin):
except ValueError:
except ValueError:
return False
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"):
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")
raise Fault("bad_value", "url is an http or https address")
u = urllib.parse.urlsplit(raw)
u = urllib.parse.urlsplit(raw)
@@ -1984,6 +2014,8 @@ class Computer(Plugin):
@@ -1984,6 +2014,8 @@ class Computer(Plugin):
raise Fault("bad_value", "url is an http or https address")
raise Fault("bad_value", "url is an http or https address")
if level != "full" and self._local_host(u.hostname.strip("[]")):
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)
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
return raw
def _browser_op(self, v, level, env=None, desk=None):
def _browser_op(self, v, level, env=None, desk=None):
@@ -2002,8 +2034,13 @@ class Computer(Plugin):
@@ -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"])):
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")
raise Fault("bad_value", "Invalid site")
selector, text, key, url = v.get("selector"), v.get("text"), v.get("key"), None
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":
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):
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)
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):
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):
@@ -2028,10 +2065,13 @@ class Computer(Plugin):
def browser():
def browser():
if env: # on a desk: its own browser (shown on a whole desk, headless on a browser desk)
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:
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():
if self.browser is None or not self.browser.alive():
b = self.browser_factory(binary, self.browser_profile, self.browser_headless)
b = self.browser_factory(binary, self.browser_profile, self.browser_headless)
+ self._set_task_origins(b, origins)
b.allow_downloads = self.effective_level() == "full"
b.allow_downloads = self.effective_level() == "full"
with self.mu:
with self.mu:
if self._generation_changed():
if self._generation_changed():
@@ -2053,6 +2093,10 @@ class Computer(Plugin):
@@ -2053,6 +2093,10 @@ class Computer(Plugin):
b = browser()
b = browser()
try:
try:
with lock(): # one action at a time in each browser; two desks' browsers act in parallel
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":
if action == "open":
out = b.open(url)
out = b.open(url)
elif action == "read":
elif action == "read":
@@ -2087,6 +2131,9 @@ class Computer(Plugin):
@@ -2087,6 +2131,9 @@ class Computer(Plugin):
out = b.back()
out = b.back()
if isinstance(out, dict):
if isinstance(out, dict):
self._desk_stream_saw_url(out.get("url"))
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
return out
except BrowserError as e:
except BrowserError as e:
raise Fault(e.code, e.message)
raise Fault(e.code, e.message)
@@ -24,11 +24,31 @@ import sys
@@ -24,11 +24,31 @@ import sys
import threading
import threading
import time
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_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)
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_LINKS = 60
MAX_ELEMENTS = 120
MAX_ELEMENTS = 120
MAX_APPEARED = 8
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_FOLLOWED = 5
MAX_SESSION_COOKIES = 3000
MAX_SESSION_COOKIES = 3000
EXACT_HTTPS_ORIGIN = re.compile(r"^https://[a-z0-9.-]+(:[0-9]{1,5})?$")
EXACT_HTTPS_ORIGIN = re.compile(r"^https://[a-z0-9.-]+(:[0-9]{1,5})?$")
@@ -174,6 +194,23 @@ FOCUS_JS = """((selector) => {
@@ -174,6 +194,23 @@ FOCUS_JS = """((selector) => {
return true;
return true;
})(%s)"""
})(%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):
class BrowserError(Exception):
def __init__(self, code, message):
def __init__(self, code, message):
@@ -235,6 +272,8 @@ class Browser:
@@ -235,6 +272,8 @@ class Browser:
self.follow_mu = threading.Lock()
self.follow_mu = threading.Lock()
self.storage_origins = set()
self.storage_origins = set()
self.after_start = None # called once the browser runs (a kept profile's session cookies go back in)
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 ------------------------------------------------------------------------------------
# ---- the process and the pipe ------------------------------------------------------------------------------------
def alive(self):
def alive(self):
@@ -491,7 +530,49 @@ class Browser:
@@ -491,7 +530,49 @@ class Browser:
def summary(self):
def summary(self):
self.used_at = time.monotonic()
self.used_at = time.monotonic()
self._follow()
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):
def open(self, url):
self.start()
self.start()
@@ -589,13 +670,25 @@ class Browser:
@@ -589,13 +670,25 @@ class Browser:
def _with_receipt(self, before):
def _with_receipt(self, before):
after = self.summary()
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 r is not None:
if self.tab_change:
if self.tab_change:
r["page"] = self.tab_change # a page it opened is now in front ("opened"), or that page closed ("closed")
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
after["receipt"] = r
return after
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):
def click(self, selector=None, text=None):
self._need()
self._need()
before = self._before()
before = self._before()
@@ -736,12 +829,18 @@ class Browser:
@@ -736,12 +829,18 @@ class Browser:
out = {"done": True}
out = {"done": True}
try:
try:
self._follow()
self._follow()
- r = receipt(before, self._eval(BEFORE_JS))
+ after = self._eval(BEFORE_JS)
+ r = receipt(before, after)
except BrowserError:
except BrowserError:
r = None
r = None
if r is not None:
if r is not None:
if self.tab_change:
if self.tab_change:
r["page"] = 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
out["receipt"] = r
return out
return out
@@ -872,4 +971,7 @@ class Browser:
@@ -872,4 +971,7 @@ class Browser:
proc.stderr.close()
proc.stderr.close()
except OSError:
except OSError:
pass
pass
+ if self.task_egress is not None:
+ self.task_egress.stop()
+ self.task_egress = None
return {"done": True, "closed": proc is not None}
return {"done": True, "closed": proc is not None}
@@ -38,8 +38,9 @@ def not_public(ip):
@@ -38,8 +38,9 @@ def not_public(ip):
class EgressProxy:
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.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.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.log = log or (lambda *a: None)
self.sock = None
self.sock = None
@@ -110,6 +111,12 @@ class EgressProxy:
@@ -110,6 +111,12 @@ class EgressProxy:
host, port = self._split(hostport, 80)
host, port = self._split(hostport, 80)
if host is None:
if host is None:
return self._answer(client, 400, "Bad Request")
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)
upstream = self._connect(host, port)
if upstream is None:
if upstream is None:
return self._answer(client, 403, "Forbidden by the agent's network guard")
return self._answer(client, 403, "Forbidden by the agent's network guard")
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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)
@@ -436,7 +436,9 @@ class RealBrowserTest(unittest.TestCase):
@@ -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.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")
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"])
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()
again = b.read()
self.assertIn('%s button "Add a row"' % refs["Add a row"], again["elements"], "the same element keeps its ref")
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"])
away = b.click(selector='[data-ov-ref="%s"]' % refs["Second page"])
@@ -518,7 +520,8 @@ class RealBrowserTest(unittest.TestCase):
@@ -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]; })()")
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)
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"]})
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.
# Type into the field it focused, select all, delete.
b._eval("document.querySelector('input[type=email]').focus()")
b._eval("document.querySelector('input[type=email]').focus()")
cmd("input", {"action": "type", "text": "[email protected]"})
cmd("input", {"action": "type", "text": "[email protected]"})
@@ -20,6 +20,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(HERE)), "sdk"))
@@ -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 import Computer # noqa: E402
from openvibe_computer.browser import Browser, find_binary # 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.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 openvibe_plugin import Fault, Runtime # noqa: E402
from test_computer import Out # noqa: E402
from test_computer import Out # noqa: E402
@@ -107,6 +108,20 @@ class EgressProxyTest(unittest.TestCase):
@@ -107,6 +108,20 @@ class EgressProxyTest(unittest.TestCase):
s.close()
s.close()
self.assertTrue(data.startswith(b"HTTP/1.1 403"), data)
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):
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.
# 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"])
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):
@@ -149,9 +164,44 @@ class PluginEgressTest(unittest.TestCase):
with self.assertRaises(Fault):
with self.assertRaises(Fault):
plugin._configure({"level": "trusted", "browser_egress": "sometimes"})
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")
@unittest.skipUnless(find_binary(shutil.which, sys.platform), "no Chromium-family browser on this machine")
class RealEgressTest(unittest.TestCase):
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):
def test_a_pages_script_cannot_reach_a_refused_address(self):
ok, no = serve(), serve()
ok, no = serve(), serve()
self.addCleanup(ok.shutdown)
self.addCleanup(ok.shutdown)
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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()