localhost is not a security boundary
Any web page your users visit can POST to their 127.0.0.1, no CORS preflight required. How we authenticate a local daemon with a token file, why denied requests still return 200, and two bugs we shipped anyway.
Unwait runs a small HTTP daemon on 127.0.0.1:4242. Claude Code hooks curl it to say "a wait started" or "the turn finished", and the app draws an overlay accordingly. The first version had no authentication, because it only listened on loopback, and loopback is local, and local is safe.
A security audit of our own design took that belief apart in about a paragraph. If you ship a desktop app with a local HTTP listener, the same paragraph probably applies to you, so here it is, with the fix we shipped and the two bugs we managed to ship anyway.
Any web page can call your loopback
The browser is a confused deputy sitting on localhost. When JavaScript on some random site does:
fetch("http://127.0.0.1:4242/wait/start", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify({ session_id: "attacker" }),
})
that request is a CORS simple request. text/plain POSTs do not trigger a preflight; the browser sends the request first and only blocks the response from being read. Your daemon executes the side effects of a request the attacker never needed a response to. Every visitor to a malicious page becomes a proxy for hitting their own machine's ports, and localhost daemons are a well-known scanning target for exactly this reason.
For us the consequence was concrete and unpleasant. The daemon's job is to display text in an overlay that floats above every window, and to raise a window of the attacker's choosing when the user clicks "Go" (the target is picked by a ppid query parameter). Unauthenticated, that is a phishing kit: put arbitrary text on top of the user's screen and decide which app comes forward when they click.
The second caller class is other local processes. We rank that lower on purpose: a malicious process running as your user can already read the app's database directly, so the daemon is not the interesting door. Threat models are allowed to say "out of scope, they are already inside".
The fix: a token the browser cannot have
The defense is three layers, and the first one does most of the work.
A shared token in a file. On every launch, the app generates a random token and writes it to ~/.unwait/daemon_token with mode 0600. The hook script reads the file at execution time and sends it as a header; the daemon compares in constant time. The property that matters: web pages cannot read files. However many drive-by POSTs a page fires, it can never present the token, so the entire browser attack class dies with one file. Regenerating per launch means a leaked token expires at the next restart, and since hooks read the file each time they fire, restarts need no re-setup.
Reject anything with an Origin header. Browser-initiated requests carry Origin; a curl from a hook script does not. Refusing requests that have one is a crude second fence that costs two lines and catches misconfigurations of the first.
File permissions as the third layer. 0600 keeps other user accounts and sandboxed processes out. It does not stop same-user processes, which is fine, because per the threat model above they were never the target.
One wrinkle worth stealing: our daemon denies with HTTP 200. A rejected request gets {"ok": false} and status 200, not 401. Hooks run in the agent's critical path and our contract is that a hook never fails or delays a turn, and some tooling treats non-2xx responses as hook errors worth surfacing. Security decides what executes; the status code stays boring. The one unauthenticated route is /health, which the installer uses to detect a running daemon and which changes nothing.
The two bugs we shipped anyway
Authentication went in, the audit was addressed, and we still shipped two holes around it. Both are the kind you only find by being your own attacker.
Query parameter injection from a directory name. The hook script appends context to the URL: ?ppid=$PPID&cwd=$PWD. Originally $PWD was escaped for spaces only. A working directory named proj&ppid=1234 would smuggle extra parameters into the query string and override the real ones, which means a repository's name, something you clone from strangers, could spoof the process id that "Go" raises or the source gate the daemon checks. The fix is full percent-encoding of anything that goes into a URL. The lesson is bigger than the fix: strings that come from the filesystem are attacker-influenced input, because people clone repos they did not name.
Two instances, one token path. Our dev build runs side by side with the release app, each with its own daemon and its own token. The hook script originally read the token from a hardcoded ~/.unwait/, so the dev instance's hooks presented the release instance's token, and every request was silently rejected. The port was right, the config was right, and nothing worked. Scripts now resolve the token relative to their own location, so each instance is self-contained. We wrote about the SSH flavor of this failure in Why your Claude Code hooks do nothing over SSH.
The checklist
If your app listens on localhost, the short version:
- Assume every web page your user visits can POST to your listener. Simple requests skip preflight; side effects execute.
- Authenticate with a secret the browser cannot obtain: a file next to your app,
0600, rotated on launch. - Reject requests bearing an
Originheader unless you specifically serve browsers. - Compare tokens in constant time. It is one function.
- Percent-encode everything that enters a URL, especially values that came from the filesystem.
- Decide what a denial looks like to your callers. A 401 is not a law of nature; our hooks get a calm 200.
- If two copies of your app can coexist, make each one's credentials self-contained before a support ticket teaches you why.
None of this took more than a day to build. The daemon it protects is the same one described in our hook payload post, running inside Unwait, where the overlay it guards shows learning cards while your coding agent works.