Ostler security model and limitations
Review browser restrictions, untrusted content, memory protections and residual risks before using Ostler with sensitive workflows.
On this page
This page is the repository's SECURITY.md. For how the memory-related rows work in practice, see Memory and privacy.
Ostler lets an AI agent drive a real browser and stores some user data. Treat it like any tool that can act on your behalf.
Supported versions#
Pre-1.0: only the latest released minor version (currently 0.2.x) and main receive security fixes.
Reporting a vulnerability#
Please use GitHub's private vulnerability reporting for this repository ("Security" tab → "Report a vulnerability"). Do not open a public issue for something exploitable. There is no bounty. This is a pre-1.0 project maintained by individuals.
Include the version, how you ran it (flags, transport), and a minimal reproduction. What to expect, on a best-effort basis:
- We acknowledge the report as quickly as we can.
- We confirm and assess the impact, and tell you if we disagree it is a vulnerability.
- We prepare a fix privately, release it, and publish an advisory crediting you unless you prefer otherwise.
- Please keep the details private until a fix is released; we ask for coordinated disclosure and will not rush you if you need time either.
Automated supply-chain checks#
The Security workflow (.github/workflows/security.yml) runs on every push to main, every pull request and weekly:
cargo denywith deny.toml: RustSec advisories and yanked crates, an allow-list of licenses, wildcard version requirements, and sources (crates.io only; no git dependencies). Duplicate versions of a crate are reported as warnings, not failures.cargo audit --deny warnings:Cargo.lockagainst the RustSec database, including unmaintained and unsound warnings.- Dependabot (
.github/dependabot.yml) opens weekly grouped updates for Cargo dependencies and GitHub Actions.
These cover known-vulnerable and disallowed dependencies. They do not detect unknown vulnerabilities, malicious but unreported crates, or flaws in Ostler's own code. Workflows use read-only tokens, do not use pull_request_target, and pin actions by tag, not by commit SHA.
Threat model#
| threat | what we do | what we do not do |
|---|---|---|
| Prompt injection from a web page | Page-derived text is wrapped in <page_content untrusted="true">: page content, but also titles, URLs, dialog text and the look legend, which used to sit on trusted lines. Text a person cannot see (opacity 0, zero font size, far off-screen, same colour as the background) is left out and counted, so a hidden instruction never reaches the model. Server instructions tell the model not to follow page text. | We cannot stop a model from being persuaded by text a person can also see. Hidden-text detection judges colour only against a solid background; gradients, images and clip-style screen-reader text are not treated as hidden. Use --allow-domain and --require-confirm-submit for anything sensitive. |
| Agent navigates somewhere it should not | Domain allowlist enforced inside the browser. Every request the page makes is held and checked whatever its type (documents, images, stylesheets, scripts, fetch/XHR, beacons, event streams), and Chrome is started with a resolver rule that makes every other host unresolvable, which also covers WebSockets and the first request of a popup. Entries may pin a port (example.com:8443). Only http(s); file: needs --allow-file. Tested with a canary server on a non-allowlisted origin: no request arrives through <img>, a stylesheet, fetch, XHR, a beacon, a WebSocket, EventSource, a form POST, a redirect, a meta refresh, a script navigation or window.open, by hostname or by IP literal. With no allowlist, --block-private-networks refuses loopback, private, link-local (cloud metadata), CGNAT and internal-looking hosts, and names that resolve to them. Independently of that flag, navigating to a literal link-local address (169.254.0.0/16 including 169.254.169.254, fe80::/10, fd00:ec2::254) or a well-known metadata hostname (metadata.google.internal) is refused unless you allowlist it. | An entry without a port allows every port on that host. Ports are checked on the requests interception sees; the browser-wide resolver rule works on hosts only, so treat a port pin as defence in depth, not isolation, for WebSockets. The private-network check resolves a name before Chrome does, so a DNS-rebinding server can answer differently the second time: use an allowlist where that matters. --block-private-networks is off by default for serve because coding agents browse local dev servers (and the demo fixtures live on loopback); it is on by default for a2a. The always-on metadata check covers navigations and literal addresses only: without an allowlist, a page's own sub-requests and names that merely resolve to a metadata address are not checked. Not a sandbox against a compromised browser. |
| Irreversible submissions | --require-confirm-submit blocks every non-GET request unless it happens inside form_submit with confirm=true. | Off by default. A GET with side effects, and WebSockets, are not covered. The first request of a popup window and requests from cross-site iframes (their own browser processes) are covered too: Chrome is told to hold such targets at start (Target.setAutoAttach with waitForDebuggerOnStart) and interception is switched on before they are released, so --allow-domain host:port pins and the gate apply to them (tested with a canary: popup POST, popup GET to another port, cross-site iframe POST/fetch/image). A submit that is cancelled mid-flight now closes the gate again. At most 8 popups are adopted (the rest are closed before they run). A popup opened by the form you confirmed inherits the confirmation for its first request only, so a hostile page that opens a window during a confirmed submit can let that popup's first request through. |
| File exfiltration through an upload field | File fields are refused unless you pass --upload-dir DIR. A path is resolved first (.. and symlinks included) and must land inside an allowed directory; the browser is handed the resolved path. | Anything inside an allowed directory can be attached to any page the agent visits, so keep it small and free of secrets. |
| A page's own "are you sure?" dialog | confirm() and prompt() are dismissed, so a site's "Delete everything?" is not confirmed by a browser nobody is watching, and the agent is told what happened. alert() and beforeunload are acknowledged. | --accept-dialogs restores auto-accept for sites that need it. A dismissed confirm can leave a flow stuck; the agent sees the dialog text and can ask you. |
| A remote agent over A2A | Loopback by default; bearer token required to listen elsewhere; Origin and Host checks against browser-based attacks; per-conversation browsers; submit needs an explicit confirm; private and internal addresses are refused unless you pass --allow-private-networks. | No TLS (use a reverse proxy). All conversations share one memory tenant. Always pass --allow-domain. See docs/A2A.md. |
| Secrets in memory | The memory API refuses password/token/card-like keys and card-number-like values; sensitive fields are never auto-filled from memory nor stored by --learn; sensitive values are masked in tool output; playbooks store structure only, never values. | Detection is heuristic (names, autocomplete tokens, digit patterns). A secret stored under an innocuous key with an innocuous value will not be caught. |
| Cross-user data exposure | Every memory point carries a tenant; every query filters on it; isolation is tested on a real Qdrant. With --isolation collection each tenant has its own collections and ostler memory token mints a JWT Qdrant itself limits to them (tested on Qdrant 1.20: other tenants answer 403). | The default shares collections, so a leaked admin key exposes every tenant. Collection isolation needs jwt_rbac and an admin who creates the collections. |
| Someone who can read your Qdrant (operator, leaked key, backup) | Payloads are sealed on your machine (AES-256-GCM); site names, labels, keys and point ids are keyed hashes; episodes are redacted of emails and numbers; plain http:// to a remote Qdrant is refused. | The vector is not hidden: with --embeddings cloud the field text is sent to Qdrant Cloud Inference, and vectors of labels reveal roughly what they say. Use --embeddings ollama to keep text local. Tenant name, win/loss counters, timing and point counts stay visible. The key is a file or env var by default (optionally the macOS Keychain or Linux Secret Service via key_store = keychain). Lose it and sealed memory is unreadable; serve refuses to start rather than silently replace a key that cannot open existing memory, and a first start only creates a key after Qdrant was seen to hold no sealed memory for the tenant (with Qdrant unreachable it refuses unless you pass --allow-new-key). Sealed blobs in the old v1 format are not read (run ostler memory seal once to upgrade them). Two simultaneous first starts on the Linux keychain are serialised by a lock file, which does not cover other programs writing the same Secret Service item; that path is tested against a scripted fake secret-tool, not a real keyring. |
| Malicious page attacking the runtime | The injected runtime avoids eval and never executes page-supplied strings; it works on DOM objects. It runs before page scripts and keeps its own references to fetch, XMLHttpRequest and setTimeout. | The runtime shares a JS world with the page, so a hostile page can replace window.__wm, patch Element.prototype / Array.prototype / JSON / Map after load, or read the runtime's functions, and so mislead what the agent is told (a field's value, whether something is visible or required) or how long a fill waits. Running it in a CDP isolated world was investigated and is not done: the activity tracking that settle depends on works by patching fetch, XHR and timers in the page's own world, which an isolated world cannot do, and the same-origin-iframe walk calls the other frame's __wm. Doing it needs a split into a small main-world tracker and an isolated-world runtime (ADR-0004). Page DOM content itself is always attacker-controlled; do not rely on the runtime for adversarial pages. |
| Local exposure | Chrome is launched with a throwaway profile and talks to Ostler over a pipe (--remote-debugging-pipe). There is no debugging port, so no other local user can connect to the browser, and Chrome exits by itself if Ostler dies. Tested: no listening socket in the browser's process tree (the WebSocket mode shows one). | OSTLER_CDP_TRANSPORT=ws brings back the old unauthenticated localhost port, for debugging only. With --profile-dir your logged-in sessions live on disk, readable by your own account and by any process running as you. |
Not audited. No security review has been done by a third party.
Verify it yourself#
Each claim above has a test that runs against a real Chrome (OSTLER_REQUIRE_CHROME=1 makes a missing Chrome a failure instead of a skip):
OSTLER_REQUIRE_CHROME=1 cargo test -p ostler-browser --test security_e2e # allowlist canary, uploads, dialogs, ports, private networks
OSTLER_REQUIRE_CHROME=1 cargo test -p ostler --test mcp_e2e page_text_never_reaches_a_trusted_line
OSTLER_REQUIRE_CHROME=1 cargo test -p ostler-browser --test forms_e2e text_a_person_cannot_see
OSTLER_REQUIRE_CHROME=1 cargo test -p ostler-cdp --test no_debug_port # no listening socket
cargo test -p ostler-browser --lib policy # URL, port, private-range and upload-path rulesThe allowlist test puts a canary server on a host outside the allowlist and loads a page that tries to reach it through an image, a stylesheet, fetch, XHR, a beacon, a WebSocket, EventSource, a form POST, a redirect, a meta refresh, a script navigation and window.open. A control run with no allowlist must reach the canary, so "nothing arrived" cannot be a broken test. The allowlist, screenshot and trust-boundary (page_text_never_reaches_a_trusted_line) tests were run against the code before their fixes and failed there. The upload, dialog, port, private-network and debugging-port tests pair each protection with its opt-out or a control run, so they cannot pass by testing nothing.
What is stored, where#
- Qdrant (your cluster): facts you saved, form playbooks (field labels and which fact filled them), episodes (error messages from forms), all tagged with your tenant. By default these are encrypted before upload (
--seal); the key ismemory.keybeside your config file orOSTLER_MEMORY_KEY. - Disk: nothing by default; a Chrome profile only if you pass
--profile-dir. - Telemetry: none.
ostler memory wipe --yes deletes everything for a tenant.