You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Media capture: macOS grants mic/cam to every origin after one app-level TCC prompt (hook checks neither manifest nor origin); Linux grants on manifest alone — no OS consent layer #25
getUserMedia is the one surface a page reaches without ever crossing the tiny.api
gate, so the launchers are the only consent layer. Today:
macOS: a vendored delegate hook answers webView:requestMediaCapturePermissionForOrigin:… with WKPermissionDecisionGrantunconditionally (native/launcher-macos.cc:2398-2417,
current main). Neither the manifest permissions key nor the calling origin is
read. The comment justifies it with "the page is the app's own code" — but that
stops being true the moment an app wraps a site (the wrapper preset, PR feat(cli): tinyjs wrap <url> — one-line site wrappers #20's tinyjs wrap) or the page navigates anywhere.
Linux: the grant is gated on the manifest (TINYJS_MEDIA from tinyjs.json "permissions", launcher-linux.cc:742-771) — but with no origin
restriction and no OS layer underneath. An app that declares microphone
hands it silently to everything it loads; interplays with the frame-blind
attribution in Linux: origin attribution is frame-blind — a hostile subframe inherits the top frame's api gate #18.
Windows: WebView2's own native per-origin prompt — already correct.
Net effect on macOS: the single system TCC prompt names the app (via the
manifest's NSMicrophoneUsageDescription), never the origin — so the consent a
user gives to "the app" silently covers every origin that app ever wraps. On
Linux: no consent moment at all beyond the developer's manifest line.
Verified at runtime (v0.42.3, bundled app, wrapper preset)
Bundled app (tinyjs build, ad-hoc) with "permissions": {"microphone": "…"},
page redirects itself to a third-party origin (local test server):
APP md=object gUM=function secure=true origin=file://
PAGE md=object gUM=function secure=true origin=http://127.0.0.1:8772
MIC GRANTED on http://127.0.0.1:8772 label=Micro MacBook Air
No WebKit per-origin prompt on the redirect — a stock WKWebView shows one there;
the hook short-circuits it.
getUserMedia({audio:true})resolves on the third-party origin with the real
microphone. (Track stopped immediately, nothing recorded — repro artifact.)
Dev mode nuance: without a bundled Info.plist usage string, navigator.mediaDevices is not exposed at all, so the hook is only reachable in bundled apps — exactly the shipped configuration.
Possible fixes (maintainer's call)
The decision handler receives the origin, so conditioning is cheap:
macOS: grant only for the app's own file:// pages (and/or origins the
manifest declares); otherwise call decision(0 /* prompt */) so WebKit's
per-origin prompt still runs, or deny. Keeps "the app's own page isn't nagged"
while wrappers stop inheriting.
Manifest parity: honor tinyjs.json "permissions" on macOS too — not
declared = never grant (matches Linux's TINYJS_MEDIA reading).
Duplicate of #24 — the same finding got posted from two parallel sessions about an hour apart. Consolidating the discussion over there (it has the fix direction and verification notes); closing this one to keep the tracker clean.
Summary
getUserMediais the one surface a page reaches without ever crossing the tiny.apigate, so the launchers are the only consent layer. Today:
webView:requestMediaCapturePermissionForOrigin:…withWKPermissionDecisionGrantunconditionally (native/launcher-macos.cc:2398-2417,current main). Neither the manifest
permissionskey nor the calling origin isread. The comment justifies it with "the page is the app's own code" — but that
stops being true the moment an app wraps a site (the
wrapperpreset, PR feat(cli): tinyjs wrap <url> — one-line site wrappers #20'stinyjs wrap) or the page navigates anywhere.TINYJS_MEDIAfromtinyjs.json "permissions",launcher-linux.cc:742-771) — but with no originrestriction and no OS layer underneath. An app that declares
microphonehands it silently to everything it loads; interplays with the frame-blind
attribution in Linux: origin attribution is frame-blind — a hostile subframe inherits the top frame's
apigate #18.Net effect on macOS: the single system TCC prompt names the app (via the
manifest's
NSMicrophoneUsageDescription), never the origin — so the consent auser gives to "the app" silently covers every origin that app ever wraps. On
Linux: no consent moment at all beyond the developer's manifest line.
Verified at runtime (v0.42.3, bundled app, wrapper preset)
Bundled app (
tinyjs build, ad-hoc) with"permissions": {"microphone": "…"},page redirects itself to a third-party origin (local test server):
the hook short-circuits it.
getUserMedia({audio:true})resolves on the third-party origin with the realmicrophone. (Track stopped immediately, nothing recorded — repro artifact.)
navigator.mediaDevicesis not exposed at all, so the hook is only reachable inbundled apps — exactly the shipped configuration.
Possible fixes (maintainer's call)
The decision handler receives the origin, so conditioning is cheap:
file://pages (and/or origins themanifest declares); otherwise call
decision(0 /* prompt */)so WebKit'sper-origin prompt still runs, or deny. Keeps "the app's own page isn't nagged"
while wrappers stop inheriting.
tinyjs.json "permissions"on macOS too — notdeclared = never grant (matches Linux's
TINYJS_MEDIAreading).TINYJS_MEDIAgrant by origin (and revisit once Linux: origin attribution is frame-blind — a hostile subframe inherits the top frame'sapigate #18'sframe attribution lands); consider surfacing some consent for wrapped
third-party origins since there's no OS layer underneath.
TODO-site-wrapper.md that wrapped sites reach mic/cam under today's rules.
Happy to take a shot at the macOS hook change as a PR if you want it.
Environment
macOS 26 arm64, tinyjs v0.42.3 (main post-#23), bundled wrapper-preset app,
ad-hoc codesign. Code read at
5bb2311.