Skip to content

Latest commit

 

History

History
155 lines (133 loc) · 12.6 KB

THREAT-MODEL.md

File metadata and controls

155 lines (133 loc) · 12.6 KB

Threat model — TinyJS App Studio (wrapper level)

What this document is. The security story for apps generated by the Studio, in user language, with every claim linked to the tinyjs issue or release that shipped it. The guarantees below are tinyjs runtime properties — the Studio surfaces, configures and proves them; it does not implement them. Credit where it's due is the point, not a caveat.

What it is not. A substitute for the runtime's own documentation (tinyjs.app/docs, changelog) or for the audit trail it links to.

The adversary

A wrapped site is untrusted web content — same trust level as a browser tab, with one crucial difference: the page holds an RPC channel to a backend process that has your user's full permissions. The entire model rests on one gate between the two.

┌──────────────────────────────────────────────────────────────┐
│  wrapped site (UNTRUSTED)                                    │
│  its JS, its iframes, its redirects, its popups              │
└──────────────┬───────────────────────────────────────────────┘
               │  window.tiny (injected into EVERY origin)
               ▼
┌──────────────────────────────────────────────────────────────┐
│  per-origin capability gate  —  tinyjs 0.38+, enforced in    │
│  the backend, never in the page                              │
└──────────────┬───────────────────────────────────────────────┘
               │  RPC over a private Unix socket / named pipe
               │  (+ session-token handshake, tinyjs 0.46)
               ▼
┌──────────────────────────────────────────────────────────────┐
│  tinyjs backend — full user-level access                     │
└──────────────────────────────────────────────────────────────┘

Out of scope, stated plainly: a malicious process running as the same user can do everything the backend can — that is not a webview problem, it is an OS problem (Electron, Tauri and every browser share it). So are physical access and 0-days in the OS webview engines themselves (WKWebView, WebView2, WebKitGTK — the OS vendors' surface).

The one sharp edge: an absent gate is an open gate

In a hand-written tinyjs.json, an absent "api" key allows every method. Precedence, in one line: enable wins over disable; "api": "wrapper" is the site-wrapper posture. Both tinyjs wrap and the Studio's finishing pass always write a gate — but if you edit a generated config by hand, deleting "api" opens everything — and on macOS before 0.50.1 that meant every frame of the page, iframes included: keychain, AppleScript, clipboard (the hole 0.50.1 closed; macOS apps showing third-party iframes should update). (Enforced in the backend at a single chokepoint; denied calls reject with a readable reason, so a hostile page's probes fail loudly, not silently. Subframes are stricter still: an api.origins key must name the iframe's origin for it to call anything.)

What the runtime guarantees (and since when)

# Guarantee Shipped Evidence
1 Deny-by-default per-origin capability gate — unlisted origins get nothing; the gate re-keys on the frame's actual origin 0.38.0, tightened 0.42.1, 0.43.0 changelog
2 Subframe gating, per platform — a hostile cross-origin iframe cannot reach the backend: Linux drops untokened calls from tokened windows (0.46); WebView2 on Windows only delivers the top-level document's messages; macOS subframe calls are marked by the launcher and run only when an api.origins key names the iframe's origin — a preset or top-level lists alone give subframes nothing, and subframe replies no longer settle the top frame's pending calls Linux 0.46.0 · macOS 0.50.1 · Windows n/a #18 — the macOS vector verified live by the suite (T6b)
3 win.open confinement — a page can only open http(s) URLs or files inside its own frontend dir; file:, javascript:, ../ walks and UNC paths are refused 0.46.0 #29
4 Authenticated transport — the app↔window pipe requires a one-time secret handshake; unauthenticated connections are dropped; single-instance pipes are per-user with ACLs (user + SYSTEM only) 0.46.0 #29
5 Storage isolation — each app gets its own data store (per-app WKWebsiteDataStore on macOS; per-app WebView2 profile on Windows since 0.45). No shared cookies/localStorage/IndexedDB between apps macOS always; Windows 0.45.0 #29
6 Built apps drop inherited TINYJS_*/WEBVIEW2_* env — no page replacement, script injection or remote-debugging flags through the environment 0.45.0 #29
7 Supply-chain pins — txiki.js downloads are hash-pinned; the build cache can no longer be poisoned through a user-level write 0.45.0 #26
8 tiny.store hardening — __proto__/constructor keys rejected (prototype pollution) 0.45.0 changelog
9 No ports. Nothing listens — page↔backend RPC runs over a private Unix socket in a per-app temp dir (named pipes on Windows); nothing to scan pre-1.0 tinyjs.app
10 Signed builds, verified updates — tinyjs build produces codesigned, notarization-ready bundles; the self-updater verifies sha256 and the code signature, swaps in place and rolls back on failure; since 0.47 it runs the bundled installer — no per-update code download from the web pre-1.0 · updater 0.47 tinyjs.app
11 Camera/mic gating — media capture goes through the gate (0.43.0) 0.43.0 changelog
12 win.printToPDF zone restrictions — from non-app origins, direct writes only to Downloads (bare filenames), the app data folder, or temp; any other path opens a save panel, cancel rejects 0.48.0 #36 — verified live by the suite (T8/T8b)
13 Malformed wire messages dropped — launchers no longer pass parts of page messages to the backend unchecked; junk shapes die before the gate 0.47.1 changelog — verified live (T10)
14 Custom URL schemes policy-gated — mailto: and friends route through onNavigate and are blocked by default; 'external' hands them to the OS 0.47.0 changelog
15 tiny-media:// proxy confined — serves only the app's own pages and the dev server; wrapped sites need "media.proxy" in their keyhole 0.47.0 #30

The wrapper preset, verbatim

What a site wrapped by the Studio may call. In: OS chrome, windows, dialogs, notifications, the app's own store, clipboard write, shell.open, theme, system info, media state, power. Out: filesystem, clipboard read, secrets, capture and automation — and the app's own API methods.

{
  "disable": ["*"],
  "enable": ["notify", "dialog.*", "win.*", "menu.*", "tray.*", "store.*",
             "clip.write", "shell.open", "theme.get", "system.locale",
             "system.capabilities", "system.requirements", "system.info",
             "app.info", "app.badge", "app.attention", "app.progress",
             "sound.play", "nowplaying.*", "power.prevent", "power.allow"]
}

API_ALWAYS = ["client.hello"] — the page's own bootstrap read-back — is the only thing that passes with no config at all.

Residual risk, by design: dialog.*, notify and shell.open let a wrapped site pop native dialogs, toasts, or hand your default browser a URL. That is the wrapper posture — a site that would phish you in a tab can phish you in its window. The gate cannot fix social engineering; the fix is wrapping sites you chose.

What the Studio adds (and owns)

  • Generated configs always carry a gate. The finishing pass keeps the CLI's api.origins mapping and, when the unread-badge option is on, grants the wrapped origin exactly one extra verb: app.badge.
  • inject.js (opt-in). Frameless chrome and the badge watcher run as injected JS in page context — it can read the page's DOM, and it is the only Studio-authored code that can. Without frameless/badge options, no inject is written at all.
  • .tinyjs-studio.json sidecar. The Studio's per-project record (form state, dots, badge, external list). A local file, never served to pages, holds no secrets.
  • Known limitation — window chrome vs redirects. The frameless drag strip's win.close/minimize/zoom are subject to the per-origin gate. When a site redirects to a stranger origin (google.fr → consent.google.com), the app's own window controls die until the reload settles. Window chrome is app machinery, not site capability: those verbs belong in API_ALWAYS. Tracked in our ROADMAP Phase 3 #14, to be proposed upstream.
  • One-click setup runs the official installer (tinyjs.app) with output streamed live — the curl | sh pattern itself is upstream item #30, being replaced by a hash-pinned flow (#35).

Known open items — not hidden, tracked

Item Status
win.printToPDF arbitrary-path clobbering Fixed in 0.48.0 — writes confined to Downloads/app-data/temp, save panel elsewhere. #36 closed; the suite's T8/T8b pair proves it from outside (temp writes, protected path doesn't)
Hardening batch 2 (tiny-media proxy, navigation schemes, curl | sh updater, setup.sh races, api-gate edges) Shipped in 0.47.0/0.47.1 — #30 closed
Media permission auto-grant in bundled apps #24 closed — media goes through the gate
Windows release/update artifacts unsigned — no provenance anchor #19 — open (PR #33 closed unmerged)
Self-update zip integrity: the hash currently comes from the same host as the zip #40 — open
Windows storage isolation shipped 0.45 without migration — wrapped sites ask to re-login once after upgrading #29 — by-design tradeoff, pairs with the Studio's "Reset site data" action
DOM popups (config "popups": "window") execute javascript: URLs in the page's own origin — opener-inherit, so no privilege gain (same wrapper gate); but the 0.46 win.open URL screening covers the bridge path, not DOM popups. Meanwhile file: popup URLs are re-keyed onto the wrapped origin (observed live: file:///etc/passwd → http://<origin>/etc/passwd, 404). Re-verified on 0.50. Surfaced by the adversarial suite (T5b) — observation, to confirm upstream

A published threat model with open items beats a marketing page with none — that is why this table exists. Each row flips to "fixed in X" as releases land, and the adversarial suite is the standing proof, outside the runtime's own CI.

For users, in one paragraph

A site wrapped by the Studio is a browser tab with a keypad bolted to its cage: it may press the buttons you bolted on (windows, dialogs, toasts, its own storage), everything else rejects at the door, its iframes and popups can't borrow its key, the app talks to its backend over a pipe only its own process knows, and updates are signed and verified. The two of us — the Studio and tinyjs — publish every hole we know about in the table above instead of waiting for someone to find them.