Report a vulnerability through GitHub private vulnerability reporting on this repository. This keeps the report private until a fix ships.
If you cannot use GitHub's reporting tool, email hi@lukehawkins.eu instead.
Do not open a public issue for a security problem.
turbofig runs one local daemon that bridges an AI client to a Figma plugin. Read this before you report a finding, so you know what is a design choice and what is a bug.
turbofig_executeruns arbitrary Figma Plugin API JS by design. This is the product. Any client that can call this tool has full script access to the open Figma file. turbofig does not sandbox or restrict the JS it runs.- Both ports bind to
127.0.0.1only. The HTTP MCP endpoint and the WebSocket server never listen on a network-reachable interface. - The HTTP MCP port rejects any request carrying an
Originheader with 403. A real MCP client (curl, a native MCP client, the file-bridge) never sends anOriginheader; only a browser does. This stops a malicious web page from calling the daemon overfetch(). TheHostheader is also allow-listed tolocalhost,127.0.0.1, and::1, which stops a DNS-rebinding attack. - The WebSocket port accepts only a null or missing
Origin. The Figma plugin UI runs in a sandboxed iframe, which reports a null origin, so the real plugin still connects. Any otherOriginis rejected at the upgrade. - The WebSocket upgrade also requires a pairing token. Origin checking
alone cannot tell the real Figma plugin UI apart from a sandboxed
<iframe>on a malicious web page: both report Originnull. See "Pairing token" below. POST /jobandPOST /mcprequire the pairing token, likePOST /control. A caller must sendAuthorization: Bearer <pairing token>. This closes the gap that an earlier version of this file documented as open: another macOS account on the same Mac can reach127.0.0.1, but it cannot read a different user's~/.turbofig/token, so it can no longer drive the Figma plugin through the HTTP port. A missing or wrong token gets 401 before the request does anything else.GET /healthsplits its payload by the same token. Without a valid token it returns onlyversionanduptimeSeconds: enough for a caller to confirm the daemon is alive. With a valid token it also returns the connected-files list and the daemon'spid./healthnever fails with 401; the reduced payload is the point, not an error.- Any local process running as the same user that already holds the
token can still call the daemon. This matches the trust model of other
local MCP servers: the token stops a different account or a sandboxed
process from reading it, not a process that already runs as the owner
and can read
~/.turbofig/tokenfor itself. - The file-bridge directories are mode
0700. The default home~/.turbofigis created and corrected to0700on every daemon start, because job and result files can carry arbitrary eval code and its output. A customTURBOFIG_BRIDGE_DIRfolder keeps whatever mode it already has; only itsinbox/andoutbox/are created and corrected to0700.
The WebSocket port (18847) requires a token query parameter on the upgrade
request, matching ~/.turbofig/token. This closes a gap Origin checking
alone leaves open: a sandboxed <iframe> on any web page reports Origin
null, the same value the real Figma plugin UI reports, so a malicious page
could otherwise open the socket and receive the AI's jobs.
- Where it lives:
~/.turbofig/token(or<TURBOFIG_BRIDGE_DIR>/tokenwhen that env var is set). 32 random bytes, hex-encoded, mode0600. Created on first daemon start; never overwritten by a later start. - Who has it: the daemon (reads it at startup) and the Figma plugin UI
(built in with
write_plugin_files, or injected locally byplugin/build-ui.tsfor a manual dev install; seeCONTRIBUTING.md). It is never logged, never returned inturbofig_statusorGET /health, and never appears in any error message.build.rsalso normalizes a localdist/ui.htmlback to the__TURBOFIG_PAIRING_TOKEN__placeholder before embedding it into the daemon binary, even if a contributor's own~/.turbofig/tokenwas baked in by a localbun run build: otherwise that real token would ship inside the binary with no placeholder left forwrite_plugin_filesto replace (daemon/build.rs'snormalize_ui_html, tested byembedded::tests::embedded_ui_html_always_carries_the_placeholder_never_a_real_token). - Comparison: constant-time, so a wrong guess cannot be distinguished by timing from a near-miss of the same length.
- To rotate it: run
turbofig stop, delete~/.turbofig/token, then runturbofig start. The daemon creates a new token and rewrites the plugin files at start. Then reopen the plugin in Figma. A cleanturbofig stopleaves the daemon stopped even with autostart on (turbofig autostart on), so theturbofig startstep is always required to finish the rotation. - Known limit on a shared Mac: if more than one local account runs turbofig, whichever account's daemon binds the ports first holds the pairing token for that session. turbofig is designed for a single-user Mac, not a Mac shared between several accounts.
- turbofig makes no outbound network calls of its own. The daemon only
serves
127.0.0.1; it never calls out to any remote service. - The plugin's manifest allows it to reach any domain
(
networkAccess.allowedDomains: ["*"]). This is deliberate: Figma rejects a wildcard port, so allow-all is the only value that covers a user-configurable daemon port (seeDECISIONS.md#23). The real limit is that JS run throughturbofig_executecould use this to make a network request from inside Figma. turbofig itself still sends nothing off the machine; narrowing the allowed domain to the daemon's configured port is planned. - The file-bridge inbox and outbox hold job and result files, including eval code and its output. The daemon prunes outbox entries (results and file-mode screenshot PNGs) older than 24 hours; a result nobody reads in that window is deleted, not kept.
daemon.logcarries operational messages only: startup, port binds, the bridge directory path, and errors from the MCP, WebSocket, and bridge tasks. It never logs job eval code or file content. Above 5 MiB it rotates todaemon.log.1at every daemon start, including a launchd relaunch under autostart, not only an ad-hoc detached start.- Release binaries are not code-signed or notarized today. Each GitHub
release ships through
cargo-distwith a Homebrew installer; the generated formula pins a SHA256 checksum for each artifact, andbrew installverifies that checksum before installing. SeeRELEASING.md's "Unsigned binaries" section for the Gatekeeper caveat. - The Homebrew tap and the releases are built and published by GitHub
Actions, not by hand. The
release.ymlworkflow'splanjob also runs on every pull request, as a dry run with no publish step; only a tagged commit builds and publishes the real artifacts and their SHA256 checksums.
/health (same HTTP port as /mcp, 18846) always answers, with no token
needed, but its payload depends on one. Without a valid pairing token it
returns only version and uptimeSeconds. With a valid token it also
returns the connected files' names, keys, plugin versions, and the daemon's
pid. It never includes the pairing token itself, and it is subject to the
same Origin and Host checks as every other route on this port (above), so
it is no more reachable from a browser or a DNS-rebinding attack than /mcp
is.
POST /job (used by turbofig mcp's stdio proxy), POST /mcp (used by a
native HTTP MCP client), and POST /control (used by turbofig stop and the
upgrade restart) bind to 127.0.0.1 only, like every other route, and carry
the same Origin and Host checks as /health. All three also require the
pairing token as a Bearer auth header: a request without the correct token
is rejected with 401 before it can run a job, call a tool, or stop or
restart the daemon.
Report a finding that breaks one of these guarantees (for example, a port
that becomes reachable from the network, an Origin check that can be
bypassed, or a file-bridge directory with loose permissions). A report that
only restates "the execute tool runs arbitrary code" is not a vulnerability;
that is the documented design.
The macOS menu-bar app (daemon/src/menu_bar/) opens 2 native windows, each
hosting its own wry webview: About (About turbofig…, or automatically on
first use) and Settings (Settings…). Both are deliberately narrow, and both
follow the same rules below unless noted otherwise.
- Each loads exactly 1 embedded page, never a URL.
about_window.rs/settings_window.rscallWebViewBuilder::with_htmlon a stringinclude_str!'d fromdaemon/assets/about/about.html/daemon/assets/settings/settings.htmlat compile time. Nowith_url, no network fetch, no remote resource of any kind: each page's own CSS and JS are inline.about_state'sthe_about_page_has_no_remote_urls_except_the_docs_linktest asserts nosrc="http/href="httpappears anywhere inabout.htmlexcept the one deliberate exception, the "Docs" link's ownhrefto the GitHub repo;settings_state'sthe_settings_page_has_no_remote_urlstest asserts the same with no exception at all forsettings.html(it has no "Docs" link). - A navigation handler blocks every navigation away from that page.
about_window::navigation_is_allowedallows only the initialabout:loadwith_htmlitself performs; the "Docs" link'sonclickintercepts the click first (prevents the default navigation and sendsopen_docsover IPC instead), and the navigation handler is a backstop that opens that one URL externally (through theAppOpenerseam, i.e.open <url>, never inside the webview) and cancels the in-webview navigation either way. The Settings window's navigation handler is the same backstop with no exception at all (no link in that page ever needs one). Nothing else ever reaches anAllowdecision in either window. - Each webview can only ever send 1 of 6 fixed IPC commands. The
About page's JS calls
window.ipc.postMessage("<command>");about_state::parse_ipc_commandaccepts exactlycopy_manifest_path,reveal_manifest,copy_agent_prompt,copy_mcp_command,copy_mcp_json,open_docs, andpage_ready, rejecting anything else (including a reasonable-looking payload like{"op":"quit"}or an unknown command) with no action taken. The Settings page sends 1 of its own 6:start_at_login_on,start_at_login_off,copy_manifest_path,open_plugin_folder,open_log,page_ready(settings_state::parse_ipc_command, the same reject-anything-else rule). Neither handler ever evaluates or interprets the raw message as code; each is a plain string compared against its own fixed literal set.reveal_manifest/open_plugin_folderboth runopen -R <manifest path>(reveal in Finder, a fixed path under<home>/figma-plugin/, never a path taken from the page), never an arbitrary path. Neither page can sendquit: Quit turbofig lives only in the tray menu now. - Every command that touches the clipboard or opens something goes
through the existing
Clipboard/AppOpenerseams, the same ones the bareturbofigcommand and the tray menu use, including their debug-build guard (a debug build never touches the real clipboard, Figma, Finder, or the browser unlessTURBOFIG_DEV_REAL_DESKTOP=1is set). start_at_login_on/start_at_login_off(Settings window only now) only ever toggle the app LaunchAgent (cli::run_autostart_on_app/run_autostart_off, see "The app LaunchAgent" below); the page cannot pass any other launchd target or plist content.page_readyonly ever triggers a re-push of status Rust already holds. It carries no data and cannot be used to request anything new; seeARCHITECTURE.md's "Live status, and the page-ready race" for why it exists.- Devtools are debug-build-only.
WebViewBuilder::with_devtools(cfg!(debug_assertions))on both windows: a release build (whatturbofig.appitself launches) never ships the Web Inspector. - The second-instance signal (
<home>/app.sock) is a local Unix socket, mode0600, carrying 1 of exactly 2 fixed literal messages (second_instance::SignalMessage):open_abouttells the first instance to open or focus the About window;quit_app(sent only byturbofig uninstall) tells it to quit, through the sameperform_quitpath as its own "Quit turbofig" menu item. Neither message can run a job, read a file, or carry arbitrary data, and (like every other<home>path) only the owning user's account can read or connect to the socket.
turbofig autostart on (the default, no --headless) writes
~/Library/LaunchAgents/eu.lukehawkins.turbofig.app.plist
(launchd::app_plist_contents): ProgramArguments is exactly
[<applications_dir>/turbofig.app/Contents/MacOS/turbofig], the bundle's
own executable, with no extra argument; RunAtLoad true; KeepAlive plain
false (a user who quits the app keeps it quit until the next login,
unlike the headless service's crash-only restart). It carries the same
TURBOFIG_* environment carry-over as the headless plist, but never
TURBOFIG_SUPERVISED: the app is not serve, so the daemon's
supervised-restart loop never applies to it. autostart on and autostart on --headless are mutually exclusive: turning either one on bootouts and
removes the other's plist first, so the 2 are never active together, and a
service plist can never point at an arbitrary attacker-supplied binary:
the only 2 possible ProgramArguments values are the bundle's fixed path
and the daemon's own stable binary path (see ARCHITECTURE.md's "Stable
binary path rule").