I Locked the Front Gate and Forgot the Back Door — A Lesson in Opening the Door and Bowing the Burglar In
A security review of my AI companion project came back with one line that made me go quiet for two minutes: 127.0.0.1 keeps the internet out, but not the other programs on your own machine. Here's what WebSocket is, how the hole got there, and what each of the three fixes actually buys you.
Last week I sent the AI companion project off for a security review, feeling pretty relaxed about it — “it’s just a desktop app, it’s not even facing the public internet, how bad could it be.” I read the first line of the report and sat in silence for a good two minutes.
What WebSocket Actually Is
First, let’s be clear about what broke.
Ordinary web requests over HTTP are like sending a telegram: your browser asks a question, the server answers once, and the line goes dead — next time you have to dial in again. WebSocket doesn’t work that way. Once it connects, the line stays open. Nobody hangs up. Either side can speak up whenever it wants, with no need to re-handshake every time.
The project needs exactly this kind of “speak the moment something happens” capability: the camera notices your expression change and has to push that to the UI immediately; the AI’s reply has to stream out token by token. Telegram-style request-and-response just can’t be that immediate.
Concretely: sidecar_server.py opens a WebSocket server on your machine that only listens on 127.0.0.1:8765 — that address means “only this machine can knock, the outside network doesn’t exist as far as this door is concerned.” The desktop frontend, once it launches, dials in with new WebSocket("ws://127.0.0.1:8765"), and from then on chat messages, face-recognition results, and config changes all travel down that one pipe as JSON.
Where It Breaks: The Gate Was Locked, the Window Wasn’t
127.0.0.1 really does keep strangers on the internet out. But there’s a line from the Analects that fits this hole almost too well. When Confucius heard that the Ji family was plotting a war over a distant territory, he said their real danger “lies not in Zhuanyu, but within the screen wall” — the xiaoqiang, the ornamental wall just inside your own gate that blocked outsiders’ view into the courtyard. His point: the threat you should actually worry about often isn’t outside your walls. It’s already standing inside them.
That’s this bug exactly. 127.0.0.1 locks “the outside can’t get in.” It does nothing about “whatever’s already running on your own computer.” Say you accidentally ran some sketchy little utility — not something written specifically to target this project, just something that happened to scan your machine and notice port 8765 had a WebSocket sitting open. It could connect like this, no more effort than that:
import websocket
ws = websocket.create_connection("ws://127.0.0.1:8765")
ws.send('{"type": "set_config", "values": {"...key..."}}')
The project already had an ALLOWED_ORIGINS check, meant to only accept connections from the app’s own frontend page — it validates the Origin header that browsers automatically attach during the handshake, a header browsers themselves guarantee won’t be forged. But the Python snippet above isn’t a browser. It can write whatever it wants into Origin, or nothing at all, entirely at its own discretion. That check works fine against an actual web page. Against something written on purpose to talk to your port, it’s basically decorative — the door was closed, but never locked, and a light push swings it open. That’s the textbook move of opening your own door and bowing the burglar straight in.
Once connected, there’s no shortage of damage available: set_config can quietly swap in the attacker’s own API key (after which every word you say gets routed through them, and the bill lands on your account); a chat message can freeload off your account directly; enroll/delete_person can tamper with the face-recognition database.
Three Options, and What Each One Actually Buys
Quick mitigation (one line): delete the exception in ALLOWED_ORIGINS that lets a connection through with no Origin header at all. This stops the lazy scripts and tools that didn’t bother forging a header — which is most of them. But anyone actually targeting port 8765 on purpose just adds Origin: http://127.0.0.1 to their request and walks right past it. This step stops the accidental; it does nothing against the deliberate.
A real pairing token (what I’d recommend): the genuine frontend window gets handed a random secret before the page even loads — injected by the Rust layer — and includes it on connect. The server rejects anything that doesn’t present the right key, no matter what Origin header it forges. This is the fix that actually seals the hole inside the walls. The cost is that this desktop shell doesn’t have any “securely hand a secret to the frontend” pipe today, so it has to be built — and the work sits almost entirely on the Rust side.
Skip it for now: ship the two Python-side fixes above and leave WebSocket authentication for a later pass.
A thousand-li dike can collapse from a single ant hole. This one isn’t wide yet, but it opens straight onto the wallet and onto privacy — I’d rather put the pairing token on the near-term list than wait for someone to actually walk through it before deciding it mattered.