8000 lense/threat-model.json at v0.11.0 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
1936 lines (1936 loc) · 101 KB

threat-model.json

File metadata and controls

1936 lines (1936 loc) · 101 KB
{
"spec_version": 1,
"repository": "https://github.com/adeelahmad/lense",
"commit": "5f799f2ff8bb8a762d8151d9b10b535a17878b55",
"date": "2026-10-05",
"scope_subpath": null,
"description": "Lens is a self-hosted archive for documents, recordings, photos and messages: it reads, transcribes, indexes and publishes them, and people search and chat over them. One operator (the instance admin) runs an instance for their own people: a FastAPI API, a Next.js web app that proxies the API and holds the sign-in session, and background workers, around one SurrealDB database, with content split into namespaces and per-namespace roles. Exposing an instance to the internet behind HTTPS (reverse proxy or Cloudflare tunnel) is supported. Only the instance admin (equivalent to host code execution) and the host operator are trusted; everyone else is trusted only as far as their role.",
"confidence": {
"documented": 246,
"inferred": 99
},
"components": [
{
"name": "web",
"entry_points": [
"every request"
],
"touches": [
"network"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
},
{
"name": "identity",
"entry_points": [
"every /api/v1/* route",
"passkey origin, OAuth discovery, external-login redirect",
"POST /auth/login, /iiif/auth/access form",
"/auth/setup, /auth/passkey/setup*, /auth/setup/no-passkey",
"/auth/refresh, /auth/ticket, /auth/signin-link/*, /auth/password/*, WebAuthn flows",
"/oauth/register, /authorize, /token, /revoke",
"/auth/external/{key}/*",
"/mcp"
],
"touches": [
"network"
],
"in_scope": true,
"provenance": "documented",
"source": "docs/authentication.md \"one-time **setup code**\""
},
{
"name": "authz",
"entry_points": [
"IP groups, throttles",
"namespace, recording and child-object routes",
"routes not read one by one (about 370)",
"media, report, embed, IIIF content",
"/iiif/auth/token, /probe, /logout",
"workers and DB peers",
"host control inbox and the monitoring scrape token",
"admin machine setup requests",
"entity and topic curation tools",
"microsandbox program requests"
],
"touches": [
"filesystem"
],
"in_scope": true,
"provenance": "documented",
"source": "SECURITY.md \"What counts\""
},
{
"name": "outbound",
"entry_points": [
"watched folders, storage sources, IMAP, iCal",
"Google Calendar sources",
"POST /imports/web, extension http tools, notification targets",
"admin settings (model, embeddings, speech, decisions, Fedora, bridge, telemetry, IIIF import, rclone remotes, IMAP, iCal, MQTT bridge, OIDC issuer)",
"replies from remote servers",
".env, archive.yaml, LENS_*, ARCHIVE_SECRET_KEY, RCLONE_BINARY"
],
"touches": [
"filesystem",
"network",
"env",
"child_processes"
],
"in_scope": true,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "ingest",
"entry_points": [
"/uploads, /import, /recordings/{rid}/files"
],
"touches": [
"filesystem",
"child_processes"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
},
{
"name": "query",
"entry_points": [
"/recordings, /search, /public/search, chat retrieval",
"/graph/query, MCP graph_query",
"/namespaces/{name}/sparql",
"/namespaces/{name}/rdf/import"
],
"touches": [
"global_state"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
},
{
"name": "assistant",
"entry_points": [
"chat, /graph/ask, MCP tools",
"extensions (prompt, graph and http tools, hooks), custom nodes including template nodes",
"Python code tools, shipped and admin-created templates, workflows, pipelines, routines",
"Lens extension side panel"
],
"touches": [
"network",
"child_processes"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
},
{
"name": "optional",
"entry_points": [
"MQTT 1883, syslog 5514, POST /sensors/push/{token}/{stream}",
"SMB share (port 1445 on the host, 4445 in the worker)"
],
"touches": [
"network",
"child_processes",
"global_state"
],
"in_scope": true,
"provenance": "inferred",
"source": "open question Q34"
}
],
"out_of_scope": [
{
"item": "ci-tooling-and-samples",
"reason": "examples/ (source clone only), overrides/ (mkdocs theme), ci/, .github/, .pre-commit* (CI, in no image) and prod-frontend-deploy.yml (stale Vercel workflow) are unsupported paths built into no running service (§1.3)",
"provenance": "inferred",
"source": "open question Q34"
},
{
"item": "inert-backend-files",
"reason": "fastapi_backend/tests/, watcher.py and commands/ are copied into the backend image but never started by the production command; a report must show production code reaching them (§1.3)",
"provenance": "inferred",
"source": "open question Q34"
}
],
"trust_boundaries": [
{
"component": "web",
"boundary": "B1, network to web app: everything a browser, app or agent sends is untrusted; the web app forwards /api, /mcp, /iiif, /embed and /s to the API, which authorizes every call.",
"provenance": "inferred",
"reachability_precondition": "reachable from a browser request and changes what the API decides or what another person's browser runs",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "identity",
"boundary": "B2, request to role: the API turns a bearer token, signed link, share link, IIIF cookie or vouched-for address into a role per namespace.",
"provenance": "inferred",
"reachability_precondition": "reachable with no account, or with an account, and yields a session, token or admin the attacker was not given; setup-code findings apply only while no account exists",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "authz",
"boundary": "B2, request to role: the main boundary; namespace isolation lives here, in application code. B5: workers, the API and the web app trust the database and each other.",
"provenance": "inferred",
"reachability_precondition": "lets a principal read or change more than its role and the asset's access level allow, or open media without a link the server signed for it",
"source" 8 ;: " 10BC8 ;threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "outbound",
"boundary": "B4, Lens to the outside: fetches whose URL a non-admin sets, and iCal feeds, go through netguard; other admin-set URLs do not; the telemetry endpoint is only shape-checked (P-ADMIN-GUARD).",
"provenance": "inferred",
"reachability_precondition": "a URL a non-admin sets, or a guarded admin operand, reaches a non-public address; or a stored secret reaches anyone but the host the admin set",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "ingest",
"boundary": "B3, content to parser: uploaded and fetched bytes cross into ffmpeg, LibreOffice, Chromium, poppler and Tesseract.",
"provenance": "inferred",
"reachability_precondition": "reachable from bytes an editor uploads or a watched source delivers, and the fault is in how Lens runs the tool (arguments, environment, timeouts, sandbox flags)",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "query",
"boundary": "B2, request to role: query text from a non-admin is untrusted and runs only over the caller's readable namespaces.",
"provenance": "inferred",
"reachability_precondition": "query text from a non-admin reads outside the caller's namespaces, writes, fetches, or reaches SurrealQL",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "assistant",
"boundary": "B3, content to parser: uploaded and fetched bytes cross into the LLM prompt; B2 bounds the tools to the person's role.",
"provenance": "inferred",
"reachability_precondition": "a non-admin (or content steering the model for one) exceeds the person's reads, changes data without approval outside notes and admin setup, or runs code on the host",
"source": "threat-model.md §1.4 (untagged boundary list)"
},
{
"component": "optional",
"boundary": "B6, devices: sensor listeners take traffic from the LAN (or wider, per sensors.bind) once an admin enables them.",
"provenance": "inferred",
"reachability_precondition": "the service is turned on by an admin, and the attacker lacks the login, network or token the feature requires",
"source": "threat-model.md §1.4 (untagged boundary list)"
}
],
"entry_points": [
{
"entry_point": "every /api/v1/* route",
"parameter": "Authorization: Bearer (web JWT, la_ API token, lo_ app token)",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "holder keeps it secret; Lens checks signature or hash, expiry, account, session",
"provenance": "documented",
"source": "docs/authentication.md \"Every request checks that the account is still active\""
},
{
"entry_point": "every request",
"parameter": "Host header",
"attacker_controllable": "yes",
"component": "web",
"caller_must_enforce": "operator lists served hosts",
"provenance": "documented",
"source": "docs/configuration.md \"checks the `Host` header\""
},
{
"entry_point": "IP groups, throttles",
"parameter": "X-Forwarded-For, peer address",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "a reverse proxy sets the header; trusted_proxies lists exactly the front hops",
"provenance": "documented",
"source": "docs/configuration.md \"Without it, a visitor can claim any address\""
},
{
"entry_point": "passkey origin, OAuth discovery, external-login redirect",
"parameter": "X-Forwarded-Host, X-Forwarded-Proto",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "TRUST_PROXY_HEADERS only behind a real proxy",
"provenance": "documented",
"source": "docs/authentication.md \"anyone could choose the address\""
},
{
"entry_point": "POST /auth/login, /iiif/auth/access form",
"parameter": "email, password",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "none — P-PASSWORDS-OFF (passwords work only where auth.passwords is on)",
"provenance": "documented",
"source": "docs/authentication.md \"With passwords off, password sign-in\""
},
{
"entry_point": "/auth/setup, /auth/passkey/setup*, /auth/setup/no-passkey",
"parameter": "setup code, email, name",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "keep the log private until the first admin exists",
"provenance": "documented",
"source": "docs/configuration.md \"so a stranger who finds a new\""
},
{
"entry_point": "/auth/refresh, /auth/ticket, /auth/signin-link/*, /auth/password/*, WebAuthn flows",
"parameter": "rotating and one-time secrets",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "holder keeps them secret",
"provenance": "documented",
"source": "docs/authentication.md \"A link lasts three days\""
},
{
"entry_point": "/oauth/register, /authorize, /token, /revoke",
"parameter": "redirect URIs, client metadata, code, verifier, refresh token",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "the app keeps state and its verifier; the person consents",
"provenance": "documented",
"source": "docs/authentication.md \"Apps register themselves\""
},
{
"entry_point": "/auth/external/{key}/*",
"parameter": "next, state, code, provider profile",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "admin chooses providers",
"provenance": "documented",
"source": "docs/authentication.md \"when the provider vouches for the email\""
},
{
"entry_point": "/mcp",
"parameter": "bearer, Origin, JSON-RPC body",
"attacker_controllable": "yes",
"component": "identity",
"caller_must_enforce": "none — P-OAUTH",
"provenance": "documented",
"source": "docs/mcp.md \"A token an app asked for another server\""
},
{
"entry_point": "namespace, recording and child-object routes",
"parameter": "namespace name, rid, child ids (sequential integers)",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "none — P-NS-ISOLATION (Lens checks the role in each handler)",
"provenance": "documented",
"source": "docs/api.md \"Namespaces you can't read answer 404\""
},
{
"entry_point": "routes not read one by one (about 370)",
"parameter": "per route",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "follow the gate pattern",
"provenance": "inferred",
"source": "open question Q8"
},
{
"entry_point": "media, report, embed, IIIF content",
"parameter": "exp, sig, full, ?s= share token, /s/<code> short code",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "holder keeps the link private",
"provenance": "documented",
"source": "SECURITY.md \"only the server's own signed links should work\""
},
{
"entry_point": "/iiif/auth/token, /probe, /logout",
"parameter": "origin, messageId, IIIF cookie, probe token",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "operator sets iiif.allowed_origins",
"provenance": "documented",
"source": "docs/iiif.md \"limits which viewer sites can get tokens\""
},
{
"entry_point": "/recordings, /search, /public/search, chat retrieval",
"parameter": "query text, filters, sort, limit, offset",
"attacker_controllable": "yes",
"component": "query",
"caller_must_enforce": "none — P-NO-INJECTION",
"provenance": "documented",
"source": "SECURITY.md \"injection into the database queries\""
},
{
"entry_point": "/graph/query, MCP graph_query",
"parameter": "Cypher text, params",
"attacker_controllable": "yes",
"component": "query",
"caller_must_enforce": "none — P-QUERY",
"provenance": "documented",
"source": "docs/graph.md \"3 million steps\""
},
{
"entry_point": "/namespaces/{name}/sparql",
"parameter": "SPARQL text",
"attacker_controllable": "yes",
"component": "query",
"caller_must_enforce": "none — P-NO-FETCH",
"provenance": "documented",
"source": "routes/rdf.py sparql docstring \"SERVICE and FROM aren't allowed\""
},
{
"entry_point": "/namespaces/{name}/rdf/import",
"parameter": "Turtle, N-Triples, JSON-LD",
"attacker_controllable": "yes",
"component": "query",
"caller_must_enforce": "none — P-NO-FETCH",
"provenance": "documented",
"source": "rdf_import.py docstring \"RDF/XML isn't read\""
},
{
"entry_point": "/uploads, /import, /recordings/{rid}/files",
"parameter": "file bytes, name, extension, size",
"attacker_controllable": "yes",
"component": "ingest",
"caller_must_enforce": "none — P-UPLOAD",
"provenance": "documented",
"source": "docs/configuration.md \"Uploads accept only the types\""
},
{
"entry_point": "watched folders, storage sources, IMAP, iCal",
"parameter": "remote file names, bytes, mail, events",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "admin creates sources",
"provenance": "inferred",
"source": "open question Q17"
},
{
"entry_point": "Google Calendar sources",
"parameter": "service account key file or connected account's refresh token, calendar ids, impersonate; the events Google returns",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "admin creates sources; read-only calendar.readonly scope; Google's own token endpoint and API address (a key file's token_uri is ignored); event text escaped into its .ics without control characters",
"provenance": "documented",
"source": "docs/processing.md \"The token endpoint is always Google's own\""
},
{
"entry_point": "POST /imports/web, extension http tools, notification targets",
"parameter": "URL",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "none — P-SSRF-GUARD",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"entry_point": "admin settings (model, embeddings, speech, decisions, Fedora, bridge, telemetry, IIIF import, rclone remotes, IMAP, iCal, MQTT bridge, OIDC issuer)",
"parameter": "URL, credentials, api_key_env",
"attacker_controllable": "no",
"component": "outbound",
< 10BC8 div class="react-code-text react-code-line-contents" style="min-height:auto">
"caller_must_enforce": "admin is trusted",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"entry_point": "replies from remote servers",
"parameter": "web page HTML and scripts, IIIF manifests, model replies, OIDC discovery, calendars and their redirects",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "treat as untrusted",
"provenance": "inferred",
"source": "open question Q17"
},
{
"entry_point": "chat, /graph/ask, MCP tools",
"parameter": "prompt text; model-chosen tool calls",
"attacker_controllable": "yes",
"component": "assistant",
"caller_must_enforce": "none — P-RETRIEVAL",
"provenance": "documented",
"source": "docs/assistant.md \"limited to what the person can read\""
},
{
"entry_point": "Lens extension side panel",
"parameter": "answers shown there (model text that can repeat third-party text), the sign-in coming back",
"attacker_controllable": "yes",
"component": "assistant",
"caller_must_enforce": "none; answers render as Markdown cleaned by DOMPurify (no scripts, images or forms; only http and https links), and the sign-in checks its state and PKCE",
"provenance": "documented",
"source": "extensions/browser/src/core/assistant.js answerHtml docstring \"cleaned of anything that could run or fetch on its own\""
},
{
"entry_point": "extensions (prompt, graph and http tools, hooks), custom nodes including template nodes",
"parameter": "tool spec, graph JSON, template text",
"attacker_controllable": "yes",
"component": "assistant",
"caller_must_enforce": "person enables only extensions they trust",
"provenance": "documented",
"source": "docs/assistant.md \"other people's run only when an admin shared them\""
},
{
"entry_point": "Python code tools, shipped and admin-created templates, workflows, pipelines, routines",
"parameter": "Python, Jinja source, regexes",
"attacker_controllable": "no",
"component": "assistant",
"caller_must_enforce": "admin is trusted",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"entry_point": "MQTT 1883, syslog 5514, POST /sensors/push/{token}/{stream}",
"parameter": "payloads, hub logins, token in the path",
"attacker_controllable": "yes",
"component": "optional",
"caller_must_enforce": "admin enables; operator firewalls",
"provenance": "documented",
"source": "docs/sensors.md \"Sensors are off until an admin turns them on\""
},
{
"entry_point": "NFS share (port 12049 on the host, 2049 in the worker)",
"parameter": "NFS and MOUNT RPCs, client address",
"attacker_controllable": "yes",
"component": "optional",
"caller_must_enforce": "admin enables and picks the account; operator firewalls",
"provenance": "documented",
"source": "docs/file-shares.md \"NFS has no login\""
},
{
"entry_point": "SMB share (port 1445 on the host, 4445 in the worker)",
"parameter": "SMB2 requests, NTLM sign-in with a file access key, client address",
"attacker_controllable": "yes",
"component": "optional",
"caller_must_enforce": "admin enables; operator firewalls",
"provenance": "documented",
"source": "docs/smb.md \"What keeps it safe\""
},
{
"entry_point": ".env, archive.yaml, LENS_*, ARCHIVE_SECRET_KEY, RCLONE_BINARY",
"parameter": "process configuration",
"attacker_controllable": "no",
"component": "outbound",
"caller_must_enforce": "keep unreadable to others",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"entry_point": "workers and DB peers",
"parameter": "DB credential, shared database rows",
"attacker_controllable": "no",
"component": "authz",
"caller_must_enforce": "run peers on trusted hosts",
"provenance": "inferred",
"source": "open question Q9"
},
{
"entry_point": "host control inbox and the monitoring scrape token",
"parameter": "inbox/upgrade request (release ref, empty for the version running, with https=0|1, public=0|1 and target=full|lean; or action=services with monitoring=on|off); /metrics scrape token and .lens-control/prometheus-token",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "the agent installs only a release tag or the install's branch, changes only the HTTPS ports, the web app's network reach and the edition (other values dropped), and only toggles the monitoring profile, so a writer can at most restart Lens with those choices or open it to the network; the token is compared in constant time and grants metrics only",
"provenance": "documented",
"source": "docs/updates.md, docs/prometheus.md"
},
{
"entry_point": "admin machine setup requests",
"parameter": "PUT /api/v1/admin/install {public, target}, POST /api/v1/settings/https/ports {open}, MCP set_install_settings",
"attacker_controllable": "no",
"component": "authz",
"caller_must_enforce": "admin is trusted; audited (lens.install, https.ports); reaches the machine only as an inbox request the agent checks again",
"provenance": "documented",
"source": "docs/updates.md \"Setting the machine up again\""
},
{
"entry_point": "entity and topic curation tools",
"parameter": "MCP edit_entity, edit_topic, merge_topics, delete_topic, undo_topic_change, undo_graph_change; assistant propose_topic_change: entity and topic ids, names, labels, definitions",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "none — the curate permission in each namespace touched and a write-capable principal (the web app's own route functions); the assistant's changes wait for the person's approval; merges and deletes are kept to undo, and audited",
"provenance": "documented",
"source": "docs/mcp.md \"curate entities and topics as an editor does\""
},
{
"entry_point": "microsandbox program requests",
"parameter": "ask/NAME.want in /data/.lens/programs: a program's name",
"attacker_controllable": "yes",
"component": "authz",
"caller_must_enforce": "the start script installs only libreoffice or chromium, each a fixed list of signed Debian packages, answers in a folder only root writes, and reads nothing but the name",
"provenance": "documented",
"source": "docs/microsandbox.md"
}
],
"environment": {
"assumes": [
"Host is Linux containers, an LXC on Proxmox, a NAS package, or a native install on Linux or macOS; the host and its root user are trusted.",
"One SurrealDB database holds every namespace, with optimistic, retried transactions; the embedded engine is for trying Lens and small archives.",
"Expiry uses wall-clock time with no skew handling.",
"Several API processes and workers may run at once; throttle counters and the setup code live in each process's memory.",
"Child processes (rclone, ffmpeg, LibreOffice, Chromium, Tesseract, poppler, cloudflared, llama-server, code tools, uv/pip) run as argument lists.",
"Listening sockets: API and web app; a loopback netguard proxy during captures; MQTT 1883 and syslog 5514 when sensors are on; llama-server when enabled.",
"Files live under data_dir, including secret.key when ARCHIVE_SECRET_KEY is unset; the server log carries the setup code and, without mail, sign-in links."
],
"does_not": [
"make outbound connections except to configured sources, model servers, mail, notification and telemetry targets, components.auto installs and model downloads, and the llama.cpp/cloudflared/hf: downloads when those features are on",
"send telemetry by default",
"check for updates"
],
"provenance": "inferred",
"source": "open question Q19"
},
"build_variants": [
{
"name": "docker-compose.yml, make dev",
"default": "dev secrets, web on 0.0.0.0:3000",
"effect": "voids P-AUTH-REQ; voids P-MEDIA-LINK; narrows P-HARDENED",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "docker-compose.prod.yml, install.sh, Cloudron, Synology, QNAP, Proxmox",
"default": "random secrets (Makefile .env writes SURREAL_PASS=root)",
"effect": "preserves P-HARDENED; preserves P-IPGROUP",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "Proxmox LXC service user",
"default": "systemd units have no User=, so Lens runs as root inside the unprivileged LXC; the SurrealDB container runs as root",
"effect": "narrows P-NO-CODE",
"discouraged": false,
"provenance": "inferred",
"source": "open question Q33"
},
{
"name": "install.sh on a headless host, NAS packages",
"default": "HTTP on all interfaces, ?code= setup URL printed",
"effect": "narrows P-SETUP-ONCE",
"discouraged": true,
"provenance": "inferred",
"source": "open question Q31"
},
{
"name": "Plain HTTP",
"default": "on",
"effect": "voids P-SETUP-ONCE; narrows P-AUTH-REQ",
"discouraged": true,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "SurrealDB root/root",
"default": "on in env defaults",
"effect": "voids P-NS-ISOLATION",
"discouraged": true,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "Image build context (.dockerignore per image)",
"default": ".dockerignore per image",
"effect": "narrows P-SECRETS",
"discouraged": false,
"provenance": "inferred",
"source": "open question Q44"
},
{
"name": "/docs and /openapi.json (OPENAPI_URL)",
"default": "on",
"effect": "no effect on claimed properties",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "tokens.oauth_enabled",
"default": "on",
"effect": "preserves P-OAUTH",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "iiif.allowed_origins",
"default": "[\"*\"]",
"effect": "narrows P-ACCESS-PARTS",
"discouraged": false,
"provenance": "inferred",
"source": "open question Q1"
},
{
"name": "server.trusted_proxies, LENS_TRUSTED_PROXY_HOSTS",
"default": "127.0.0.0/8, ::1; compose names its containers",
"effect": "narrows P-IPGROUP; narrows P-THROTTLE",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"Trusted proxies\""
},
{
"name": "TRUST_PROXY_HEADERS (web app)",
"default": "off",
"effect": "narrows 10BC8 P-OAUTH",
"discouraged": false,
"provenance": "documented",
"source": "docs/authentication.md \"anyone could choose the address\""
},
{
"name": "server.allowed_hosts, ARCHIVE_ALLOWED_HOSTS",
"default": "127.0.0.1, localhost",
"effect": "voids P-HOST",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"stops DNS rebinding\""
},
{
"name": "server.secure_cookies",
"default": "false",
"effect": "narrows P-BEARER-ONLY",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"Put everything behind HTTPS\""
},
{
"name": "auth.passwords",
"default": "off on fresh installs",
"effect": "voids P-PASSWORDS-OFF",
"discouraged": false,
"provenance": "documented",
"source": "docs/authentication.md \"With passwords off\""
},
{
"name": "documents.web_networks, LENS_WEB_NETWORKS; notifications.networks",
"default": "empty",
"effect": "narrows P-SSRF-GUARD; narrows P-ADMIN-GUARD",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"loopback and cloud metadata addresses stay out\""
},
{
"name": "Chromium sandbox",
"default": "off as root or without user namespaces",
"effect": "narrows P-CONVERT-OFFLINE",
"discouraged": false,
"provenance": "documented",
"source": "docs/configuration.md \"runs with its sandbox where it can\""
},
{
"name": "sensors.enabled, sensors.mqtt_anonymous",
"default": "off (bind 0.0.0.0); off",
"effect": "narrows P-SENSORS",
"discouraged": false,
"provenance": "documented",
"source": "docs/sensors.md \"Sensors are off until an admin turns them on\""
},
{
"name": "tunnel.mode, local_llm.enabled, telemetry",
"default": "off",
"effect": "preserves P-TELEMETRY; preserves P-CHILD-SECRETS",
"discouraged": false,
"provenance": "documented",
"source": "docs/remote-access.md \"It's off until an admin turns it on\""
},
{
"name": "components.auto",
"default": "on",
"effect": "preserves P-MODEL-CHECKSUM",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "encryption.files",
"default": "on for new archives",
"effect": "voids P-ENCRYPTION",
"discouraged": false,
"provenance": "documented",
"source": "docs/encryption.md \"A new archive turns this on at its first start\""
},
{
"name": "ai.extensions, ai.disabled_tools",
"default": "on, none",
"effect": "preserves P-EXT-SCOPE",
"discouraged": false,
"provenance": "documented",
"source": "docs/assistant.md \"Extending the assistant\""
},
{
"name": "ALGORITHM, MEDIA_URL_EXPIRE_SECONDS, ACCESS_TOKEN_EXPIRE_SECONDS",
"default": "HS256, 21600, 900",
"effect": "narrows P-LIFETIMES; narrows P-MEDIA-LINK",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"name": "VALIDATE_CERTS (mail), IMAP security: none, http:// model URLs",
"default": "certs checked",
"effect": "narrows P-SECRETS",
"discouraged": false,
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
}
],
"adversaries": {
"in_scope": [
"anonymous-internet-visitor",
"link-holder",
"signed-in-person-no-role",
"namespace-viewer-editor-owner",
"api-token-oauth-app-mcp-agent",
"content-author",
"lan-host-or-device",
"network-attacker-on-path"
],
"out_of_scope": [
"instance-admin",
"host-operator-log-readers-backup-recipients",
"worker-host-db-credential-holder",
"upstream-publishers-cloudflare-providers"
],
"provenance": "inferred"
},
"properties_provided": [
{
"property": "P-NS-ISOLATION",
"violation_symptom": "info-leak, integrity-bypass",
"severity_tier": "security",
"conditions": "a principal with no role in a namespace and no grant for an asset cannot read or change anything in it, except what the asset's access level opens to anyone (P-ACCESS-PARTS); the namespace and its assets answer 404; grants for one asset open only that asset's visitor pages and IIIF",
"provenance": "documented",
"source": "SECURITY.md \"reading or changing anything in a namespace you have no role in\""
},
{
"property": "P-ACCESS-PARTS",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "a closed part of a public asset, and any restricted or private asset, opens only for a role, permission, IP group or link; a closed text part stays out of search and downloads; only owners change access and open parts",
"provenance": "documented",
"source": "docs/access.md \"the parts of a public asset anyone may use\""
},
{
"property": "P-ROLE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "a principal does no more than its role allows; writes need a write-capable principal and a role with the permission the route names (or editor or owner where a route still asks for a rank); publishing and access changes need the publish permission, through every API path; a custom role counts as the largest built-in role it covers and never does more than its permissions",
"provenance": "documented",
"source": "SECURITY.md \"doing more than your role allows\""
},
{
"property": "P-ROLE-GRANT",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "only admins make, change and delete roles; built-in roles never change; a non-admin with the members permission gives only roles whose permissions they hold all of, and changes only people whose role they hold all of",
"provenance": "documented",
"source": "docs/access.md \"they can't promote themselves or take out an owner\""
},
{
"property": "P-NO-INJECTION",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "request text does not inject into database queries, a shell or the processing tools",
"provenance": "documented",
"source": "SECURITY.md \"injection into the database queries, the shell\""
},
{
"property": "P-MEDIA-LINK",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "media, frames, documents and reports open without a session only through a link the server signed for that path, or a live share link; text people or models write is never signed",
"provenance": "documented",
"source": "docs/architecture.md \"is never signed\""
},
{
"property": "P-SHARE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "a share link opens one asset, read-only; it expires and can be revoked; only hashes are stored; a dead link answers the same 410 whatever went wrong",
"provenance": "documented",
"source": "docs/authentication.md \"give read-only access to one asset's player and embed\""
},
{
"property": "P-AUTH-REQ",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "a request is authenticated only if its bearer verifies and the account and session are live; sign-out and disabling take effect at once, for every token kind; revoking an app grant or API token ends its tokens at once",
"provenance": "documented",
"source": "docs/authentication.md \"Every request checks that the account is still active\""
},
{
"property": "P-REMOVE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "removing an account ends every way it signs in at once and takes every role, collection role and recording permission; its email can make a new account, which inherits nothing; admins only, never yourself, never the Matterbridge account, never the last passkey into a vault unless the admin agrees to lose its files",
"provenance": "documented",
"source": "docs/authentication.md \"Removing someone\""
},
{
"property": "P-BEARER-ONLY",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "/api/v1 takes bearer tokens only, never cookies; the IIIF authorization flow and the web app's NextAuth handlers are the cookie surfaces",
"provenance": "documented",
"source": "docs/architecture.md \"so there is no CSRF surface\""
},
{
"property": "P-SETUP-ONCE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "no public sign-up; only the one-time setup code makes the first admin, and it dies once an account exists",
"provenance": "documented",
"source": "docs/configuration.md \"so a stranger who finds a new\""
},
{
"property": "P-ONCE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "sign-in links, WebAuthn flows, sign-in tickets, OAuth codes and external-login states work once; a refused or failed attempt consumes nothing",
"provenance": "documented",
"source": "docs/authentication.md \"works once\""
},
{
"property": "P-REUSE",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "a rotated web refresh token replayed after 60 s ends the whole session; a rotated OAuth refresh token replayed within 60 s is refused and changes nothing, after that it ends the grant; a spent OAuth code replayed ends the grant",
"provenance": "documented",
"source": "docs/authentication.md \"A swapped one that comes back within 60 seconds is refused\""
},
{
"property": "P-LIFETIMES",
"violation_symptom": "bad-data-accepted",
"severity_tier": "correctness",
"conditions": "access JWT 900 s; sign-in link 72 h, emailed links 60 min; OAuth code 5 min; app tokens 60 min / 30 days; API tokens default 90 days, never past tokens.max_days",
"provenance": "documented",
"source": "docs/authentication.md \"A link lasts three days\""
},
{
"property": "P-OAUTH",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "PKCE (S256) required; redirects must match a registered URI; addresses with a user name or backslash refused; the consent page cannot be framed; app tokens act as the person and never administer; /mcp refuses a token issued for another server, caps bodies at 1 MB and batches at 20",
"provenance": "documented",
"source": "docs/authentication.md \"PKCE is required\""
},
{
"property": "P-PASSWORDS-OFF",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "with passwords off, password sign-in, changes and resets answer 403",
"provenance": "documented",
"source": "docs/authentication.md \"With passwords off, password sign-in\""
},
{
"property": "P-THROTTLE",
"violation_symptom": "x-brute-force",
"severity_tier": "correctness",
"conditions": "failed password sign-ins are throttled per email and address (8 per 15 minutes); unknown emails take as long to reject as wrong passwords; in both compose files each visitor gets their own throttle",
"provenance": "documented",
"source": "docs/authentication.md \"throttled per email and address\""
},
{
"property": "P-IPGROUP",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "an address opens an IP group only when the server can vouch for it, through server.trusted_proxies or LENS_TRUSTED_PROXY_HOSTS; both compose files trust the web app container so IP groups see real addresses; elsewhere a reverse proxy must set X-Forwarded-For (other variants: Q2)",
"provenance": "documented",
"source": "docs/access.md \"An address the server can't vouch for opens nothing\""
},
{
"property": "P-HOST",
"violation_symptom": "bad-data-accepted",
"severity_tier": "correctness",
"conditions": "the Host header must be a served host; this stops DNS rebinding",
"provenance": "documented",
"source": "docs/configuration.md \"stops DNS rebinding\""
},
{
"property": "P-FRAME",
"violation_symptom": "integrity-bypass",
"severity_tier": "security",
"conditions": "the API sends a strict CSP; only the player (/embed/<id>, /s/<code>) can be framed, and only by server.embed_frame_ancestors",
"provenance": "documented",
"source": "docs/configuration.md \"can be framed\""
},
{
"property": "P-HARDENED",
"violation_symptom": "x-missing-hardening",
"severity_tier": "correctness",
"conditions": "in the compose files the database, API and Fedora ports bind to 127.0.0.1, containers run non-root with no-new-privileges, and the web app sends nosniff, a referrer policy, a permissions policy and a frame policy (HSTS through Cloudflare); in docker-compose.prod.yml the DB password is random and the web app listens on this machine unless LENS_BIND is set",
"provenance": "documented",
"source": "docs/deployment.md \"What's hardened already\""
},
{
"property": "P-SECRETS",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "stored secrets (source credentials, notification secrets, model API keys, iCal addresses) are encrypted and write-only; the API reports whether one is set, never its value; a key from the environment is never shown; they do not leak through the API, logs, exports, or a redirect to another server",
"provenance": "documented",
"source": "SECURITY.md \"stored credentials for sources and model servers leaking\""
},
{
"property": "P-SSRF-GUARD",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "URLs a non-admin sets (web import and extension http tools by editors; notification targets by owners) reach public addresses only, or networks the operator listed; web paths use ports 80 and 443, follow redirects only to http and https, re-check every redirect and sub-request, and connect to the checked address",
"provenance": "documented",
"source": "maintainer ruling, 2026-10"
},
{
"property": "P-ADMIN-GUARD",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "guards Lens documents on admin-set operands hold: iCal feeds are fetched like web captures (public addresses only and documents.web_networks, ports 80 and 443, the password never goes on to a server the calendar redirects to); the telemetry endpoint cannot carry a user name or password or be a link-local address",
"provenance": "documented",
"source": "docs/processing.md \"fetched the way web pages are captured\""
},
{
"property": "P-NO-FETCH",
"violation_symptom": "info-leak",
"severity_tier": "security",
"conditions": "SPARQL refuses SERVICE and FROM and never loads a named graph; RDF import fetches nothing, refuses remote JSON-LD contexts and does not read RDF/XML",
"provenance": "documented",
"source": "rdf_import.py docstring \"Nothing is fetched while reading\""
},
{
"property": "P-QUERY",
"violation_symptom": "integrity-bypass, hang",
"severity_tier": "security",
"conditions": "Cypher is read-only and runs on the caller's readable namespaces; a query stops within 3 million steps or 8 seconds; paths run up to 8 hops",
"provenance": "documented",
"source": "docs/graph.md \"3 million steps\""
},
{
"property": "P-RETRIEVAL",
"violation_symptom": "info-leak, hang",
"severity_tier": "security",
"conditions": "chat, assistant tools and MCP return only what the person can read, within the conversation's scope, and the tool loop stops at ai.max_steps and ai.max_transcript_reads",
"provenance": "documented",
"source": "docs/assistant.md \"limited to what the person can read\""
},
{
0