A deliberately hostile page, wrapped and run exactly like any app the Studio generates, attacking the gates it lives behind. It is the standing, outside-the-runtime-CI companion to docs/THREAT-MODEL.md: every probe maps to a guarantee (or a known open item), and every row links the tinyjs issue/release behind it.
Scope note: the runtime audit (#26/#29/#30…) tested tinyjs from the inside. This suite tests the wraps the Studio ships — the wrapper preset, the injected page, cross-origin subframes, and the effects a page cannot hide from the outside.
sh test/adversarial/run.sh # ~28s; a probe window appears briefly # or: TINYJS_BIN=/path/to/tinyjs sh test/adversarial/run.sh 30
The harness (run.sh) starts a two-port static server (serve.mjs — 8787 is
the wrapped origin, 8788 exists so the suite's iframe is genuinely
cross-origin), runs tinyjs wrap on the hostile site into a temp dir, runs
tinyjs dev for a few seconds, then checks externally. Three evidence
channels, on purpose:
- Filesystem — the printToPDF pair: T8 writes into temp (the allowed
zone since the 0.48 #36
fix — expected present), T8b aims at
$HOME(must NEVER appear; since 0.48 that call opens a save panel, which also stalls the launcher's wire pump — so T8b always fires last, after the verdict relay is out). - Server log — which URLs the page/popup actually reached: a
GET /etc/passwdon the wrapped origin proves thefile:popup was re-keyed (no disk access); aGET /exfilproves thejavascript:popup executed. - Clipboard — the page relays its full verdict table out via
clip.write, an allowed verb of the wrapper preset (macOSpbpaste;⚠️ it clobbers your clipboard during the run).
Verdicts also render in the probe window's table. Each run wraps under a
unique app title — dev webviews persist a per-title WKWebsiteDataStore,
so a repeated title replays last run's cached probe scripts. Artifacts stay
in the harness's /tmp/tjs-adv-* dir (wrap.log, dev.log, serve.log).
| id | attack | result | evidence |
|---|---|---|---|
| T1 | fs.read from the wrapped origin |
PASS — blocked: "fs.read" is disabled by tinyjs.json "api" |
gate, 0.38+ |
| T2 | clip.read |
PASS — blocked (write is in, read is out) | gate |
| T3 | debug.get |
PASS — blocked | 0.42.1 fix |
| T4 | spotlight.index |
PASS — blocked | gate |
| T5a | win.open('file:///etc/passwd') |
PASS — re-keyed: popup nav rewritten to http://<origin>/etc/passwd, 404, disk untouched |
serve.log + dev.log [nav] |
| T5b | win.open('javascript:fetch(…/exfil)') |
INFO — same-origin execution: the popup runs in the opener's origin (no privilege gain — same wrapper gate). The 0.46 win.open screening covers the bridge path, not DOM popups — re-verified on 0.50, flagged upstream |
serve.log /exfil hit |
| T5c/d | ../ walk, UNC path |
manual — handle returned, nothing fetched from a stranger host | serve.log |
| T6 | cross-origin iframe (port 8788) calling the bridge | PASS — subframe isolated: stronger than expected — window.tiny is not even injected into the stranger-origin subframe |
page verdict relayed via clipboard |
| T6b | cross-origin iframe calling the RAW WebKit handler (window.webkit.messageHandlers.tiny, the seq:json wire format directly) |
PASS — gated, proven live: fired post-report by parent trigger; the backend stamps the call with the iframe's OWN origin — tinyjs: denied "shell.open" for subframe http://127.0.0.1:8788 (tinyjs.json "api".origins) — and the shell.open decoy (/raw-leak) never reaches the decoy server. Probes the exact macOS vector the 0.50.1 release gated |
dev.log denial + serve.log absence |
| T7 | tiny.store __proto__/constructor pollution |
PASS — inert data: keys persist as plain data, Object.prototype untouched (the 0.45 fix sanitizes rather than rejects) |
page read-backs |
| T8 | win.printToPDF into temp |
PASS — allowed zone: direct write, by design since the 0.48 #36 fix | harness filesystem check |
| T8b | win.printToPDF at a protected path ($HOME) |
PASS — protected: no file ever appears; save panel opens (and dies with the teardown) | harness filesystem check |
| T9 | postMessage spoof barrage incl. fake win.close |
PASS — no effect: app alive through the barrage; the backend is socket-only | harness liveness check |
| T10 | 15 malformed wire payloads (junk shapes around an allowed verb, one with a spoofed origin field) | PASS — dropped or rejected, nothing executes (0.47.1 fix); the spoofed-origin canary is denied on the frame's real origin | harness liveness check |
Runtime quirk surfaced by T6b (flagged upstream via the maintainer, not a
security issue): a DENIED raw subframe call is followed by dev-app
termination — ~15–30 s later on the pre-0.50.1 build, within seconds on
0.50.1. That is why T6b fires LAST, after the verdict report has relayed,
and is judged by external evidence (the dev.log denial line vs a server
log leak hit). The harness classifies late death as LIVENESS INFO (report
relayed) versus LIVENESS FAIL (death with no report — the signature of an
executed spoofed verb).
- External-link policy: wrap with
--external trusted.example, click a link to a listed domain (must open in the default browser) and an unlisted one (must stay in-app). - Redirect re-keying: wrap google.fr; let it redirect to
consent.google.com — the gate must re-key (it does); the frameless drag
strip's window verbs die (known Studio limitation,
ROADMAP Phase 3 #14 — fix proposed upstream: window
chrome belongs in
API_ALWAYS). - Built-app env scrubbing: build a wrap, then from the page dump
process-adjacent env via the bridge —TINYJS_*/WEBVIEW2_*must be absent in the built app (they are present undertinyjs dev, which is a developer context, by design). - Media consent: wrap a getUserMedia page — camera/mic must go through the gate and surface a consent path, never auto-grant (#24 tracks the bundled-app case).
PASSis the good column.FAIL — Object.prototype polluted,FAIL — PROTECTED PATH WRITTENor an executed escape is a finding: file it upstream (private advisory for exploitable classes — see SECURITY.md), never a public issue.INFOrows are behavioral observations with no privilege gain — worth telling upstream, not worth alarming users.CHECK manuallymeans neither the page nor the harness could observe the outcome alone — the manual list above decides it.