Skip to content

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

Description

@slabbdev

Summary

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
    WKPermissionDecisionGrant unconditionally (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).
  • Linux: scope the TINYJS_MEDIA grant by origin (and revisit once Linux: origin attribution is frame-blind — a hostile subframe inherits the top frame's api gate #18's
    frame attribution lands); consider surfacing some consent for wrapped
    third-party origins since there's no OS layer underneath.
  • Docs: correct the "the page is the app's own code" comment and note in
    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.

Activity

  1. slabbdev commented on Sep 30, 2026

    @slabbdev
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions