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 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.
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
Hostheader is not one of the loopback names we answer to is refused with a 421. That is what kills DNS rebinding, where a page getsevil.exampleto resolve to127.0.0.1and then talks to us as itself. - Every request that is not a read requires an
Originwe serve. A request with noOriginat all passes: that iscurl, 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-Sitesayscross-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 carryContent-Security-Policy: default-src 'none'; sandbox, so a browser talked into treating one as a document gets an inert one.
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-srcat all:fetch,XMLHttpRequestandWebSocketare dead inside a widget. Every call goes throughFremkit.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.
localStoragethrows 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.
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.
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.
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.comand nowhere else, and is never stored or logged. - Connection secrets live in the macOS keychain (or
data/secrets.jsonif 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 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 bycodesignalone. 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.
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.
allowSelfSignedexists 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/usageis 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
fetchresolves it again; a name that changes between the two is not caught. What is caught is a widget simply asking for a private address.
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.