Skip to content

Security: fdussert/fremkit

SECURITY.md

Security

Fremkit is a dashboard for one person's own Mac. That shapes everything below: what it defends against, what it deliberately does not, and what you are trusting when you run it.

The shape of it

The server listens on 127.0.0.1:4242 and nowhere else. It has no authentication, by design: anything that can open a socket to it is already running as you on your Mac, and a process running as you can read your keychain whatever Fremkit does. Adding a password would protect nothing and would be one more secret to keep.

That leaves three attackers worth defending against, and the code is written around them.

A web page in your browser

Any page you visit can try to talk to 127.0.0.1:4242. It cannot read most answers — the browser enforces that — but it can send, and it can embed. So:

  • Every request whose Host header is not one of the loopback names we answer to is refused with a 421. That is what kills DNS rebinding, where a page gets evil.example to resolve to 127.0.0.1 and then talks to us as itself.
  • Every request that is not a read requires an Origin we serve. A request with no Origin at all passes: that is curl, the Claude Code hook scripts and the native helper, none of which is a browser doing cross-site work.
  • The reads that cause something — fetching a favicon, proxying for a widget, asking a device for its options, downloading a backup — additionally refuse a request whose Sec-Fetch-Site says cross-site, which is the only signal a cross-site <img> or <script> gives.
  • Every answer carries X-Content-Type-Options: nosniff, and the ones that hand back bytes from elsewhere also carry Content-Security-Policy: default-src 'none'; sandbox, so a browser talked into treating one as a document gets an inert one.

A third-party widget

A widget is a folder of HTML and JavaScript. It is untrusted code, and it runs in a sandbox="allow-scripts" iframe and under a CSP served with its own files, so the two hold even if someone opens /widgets/<id>/index.html directly. The document has an opaque origin: nothing it does counts as coming from http://127.0.0.1:4242.

What a widget cannot do:

  • Reach the network directly. There is no connect-src at all: fetch, XMLHttpRequest and WebSocket are dead inside a widget. Every call goes through Fremkit.fetch, the host, and the proxy, which only allows the hosts the manifest declares — never a private, loopback, link-local or reserved address, in any spelling.
  • Read a channel its manifest does not declare, or send a command on one. config, which carries the whole dashboard, cannot be declared at all.
  • Act on something that is not its own. A command that does something on your behalf — pressing a shortcut button, probing a service — names only its own instance, and the host stamps that identity itself; the target is then read from your saved settings. A widget cannot press another widget's buttons, and cannot name an application or a URL of its own.
  • Keep talking after navigating itself. If a widget replaces its own document, the host drops its subscriptions and stops answering it.
  • Store anything. localStorage throws in an opaque origin.

What a widget can do, and what that means for you: a widget that declares homey:* can drive every settable device on your Homey, and one that declares shortcuts can press the buttons you configured on its own tile. The admin shows what each widget asks for — under the widget in the library, and in full in its inspector — so read that before installing one from elsewhere.

An installed widget

A widget from the registry is the same untrusted code as any other, in the same sandbox, under the same CSP. Two things are different.

It is held to what you accepted, not to what it asks for. Installing it records the three permission lists as they were shown to you; the manifest on disk is only the ask, and everything downstream — the channels the host relays, the hosts the proxy fetches — is handed the intersection of the two. A channel the manifest declares and your record does not is refused exactly like one that was never declared. That is what makes "an update whose permissions grew asks again" a property of the server rather than a promise the dialog makes: an update that quietly widened its own manifest would gain nothing by it. An installed widget with no record at all — a folder dropped into data/widgets by hand — gets nothing.

And it is checked on the way in. The index is fetched over https from one host, every URL it names must be on that host, a redirect is refused, and the package's sha256 is verified before a single zip entry is read. Then the package rules run again on this side — no dotfile, no symlink, no climbing path, no nested archive, size ceilings, a manifest that validates, an sdk this build speaks, an id that is the one you asked for and is not a built-in's, and no private host in permissions.network. The registry applies the same rules at pull-request time; neither side trusts the other. The whole chain is described in docs/marketplace.md.

What none of that protects you from is a widget that does exactly what it said and is simply malicious about it. The registry is curated by pull request and the review reads what a widget does with the permissions it asks for — but the thing to read before pressing Install is the list in the dialog.

A widget may also declare a connection — a service it needs credentials for, described in its own manifest rather than coded into Fremkit. What that widget can reach is narrower than it sounds, and deliberately so. It never holds the credential: it asks the server for a path, and the server checks the method and that path against the exact list of requests you were shown before installing, adds the credential, and returns the answer. Anything not on the list is a 403. The credential goes to the address you typed and to nothing else — a redirect is an error rather than a hop, and the address cannot be changed without the key being asked for again. A path that a service might route differently from the matcher (.., %2f, an empty segment) is refused before it is matched. The widget may set a body and two headers, Content-Type and Accept; Authorization, Cookie and Host are refused, because each is a way of reaching past the proxy. Plain HTTP is honoured only for an address on your own network, where there is no certificate to be had.

The thing this does not protect you from is the same as above: a widget doing exactly what it declared, on data you agreed it could reach. The list in the dialog is the thing to read.

Remote data rendered by a widget

Calendar titles, volume names, printer fields, pull request titles: none of it is yours, all of it reaches a screen. The bridge provides Fremkit.esc(), Fremkit.el() and Fremkit.color(), and the widgets here use them. See the Security section of docs/writing-widgets.md.

What Fremkit reads on your Mac

Transcripts, the clipboard, Dock badges, a keychain item — the full list, with who reads what and when, is in the README. Two things worth repeating here:

  • Reading the Claude Code OAuth token from the keychain is off by default and turned on explicitly in /admin → Screen → Privacy. It is presented to api.anthropic.com and nowhere else, and is never stored or logged.
  • Connection secrets live in the macOS keychain (or data/secrets.json if you chose the file backend). They are never returned by the API, never logged, never quoted in an error, and never included in a backup. A stored secret also cannot follow a changed destination: change the host of a connection and you are asked for the secret again.

The native helper

The helper (native/) is a small AppKit app that owns the kiosk window, the HID touch driver and the Dock badges, and supervises the server process.

  • Its TCC grants are real. Input Monitoring and Accessibility let it read the touch panel and post synthetic mouse events. Those are powerful permissions, granted to that bundle, and they cover the helper only — not the Node server it spawns, which is an ordinary child process.
  • It is signed with a self-signed identity created by scripts/create-signing-identity.sh ("Fremkit Helper Dev"), because macOS ties TCC grants to the signature: without a stable one you would re-approve the permissions after every build. That identity is trusted for code signing in your login keychain only — nothing is added to the system trust store — and its private key is imported non-extractable and usable by codesign alone. It is not notarised, and it is not a substitute for a Developer ID.
  • The kiosk cannot be navigated away from: a main-frame navigation off the dashboard's origin is cancelled, and WebKit's context menu is removed, because the Edge has no keyboard and no window chrome to get back with.
  • macOS Local Network permission belongs to the responsible app — "Fremkit Helper" under the helper, your terminal otherwise. Deleting the helper's bundle drops the grant, which is why the install script replaces its contents in place.

Known limits

These are real and not fixed. They are here so you can decide whether they matter to you.

  • Bambu Lab LAN mode has no certificate to verify. The printer serves a self-signed certificate on both the MQTT port and the camera port, so Fremkit connects without verifying it: the traffic is encrypted but the peer is not authenticated. Anything on the same network that can answer for the printer's address can receive the LAN access code. See docs/connections.md.
  • A same-user process is out of scope. It can read your keychain, your config and your clipboard without going through Fremkit at all.
  • The registry is trusted to be curated, not to be safe. An installed widget can do everything you granted it, and a permission list that looks reasonable can still be used badly — there is no sandbox that tells "shows my calendar" from "sends my calendar somewhere". The index carries no signature: the trust is in GitHub Pages serving what the workflow built, and in the hash and the https host, which stop the bytes changing on the way rather than proving who wrote them.
  • A Synology on the LAN has no certificate to verify either. allowSelfSigned exists for exactly that, off by default and per connection; turned on, the traffic is encrypted but the peer is not authenticated. See docs/connections.md.
  • The Claude usage endpoint is undocumented. api.anthropic.com/api/oauth/usage is not a published API and may change or disappear.
  • DNS rebinding has a race no application can win. The proxy resolves a host and judges the address, then fetch resolves it again; a name that changes between the two is not caught. What is caught is a widget simply asking for a private address.

Reporting something

Please do not open a public issue for a vulnerability. Use GitHub's private vulnerability reporting on this repository — the Security tab, then Report a vulnerability — which opens a private thread with the maintainer.

Useful in a report: what an attacker has to control (a web page you visit, a widget you installed, a device on your network), what they get, and the shortest way to reproduce it. There is no bounty; this is one person's side project, and it will be read and answered.

There aren't any published security advisories