DEV Community

Cover image for A trust pin with no undo is an outage you shipped on purpose
Chad Priest
Chad Priest

Posted on Originally published at blog.vodou.ai

A trust pin with no undo is an outage you shipped on purpose

If your agent stack has a browser in it, you have a loopback bridge: a WebSocket on 127.0.0.1 that a browser extension opens and a local server answers. And once you have that, someone on your team eventually notices that any page can open a socket to localhost, and hardens it. The usual hardening is extension-ID pinning: record the ID of the extension that connects, refuse everything else. It is the right instinct. The Lightpanda session bridge writeup walks through the same fix, down to the 403.

I shipped that in v0.6.28. Then I read my own code again and found that it could lock a user out of their browser bridge permanently, with no UI saying why and no way back short of editing a database row by hand.

This post is about the second half of that fix, which nobody writes up: the undo.

Whichever browser connected first won, and every other one was refused forever

The pin was unconditional. First connection writes its extension ID into a settings row, every later connection is compared against it.

Extension IDs are per-browser and per-build. Chrome and Firefox hand out different IDs for the same extension. An unpacked dev build and the Web Store build of the same extension have different IDs, because Chrome derives an unpacked ID from the filesystem path. The dev.to post names that trap too, and it is the one everybody hits first.

Mine was worse, and it was mine alone. The code that wrote the setting only ever ran on a chrome-extension:// origin. The Firefox branch never wrote it. So a Firefox bridge was not merely unpinned, it was unrepresentable: there was no value the system could have stored that would let it back in.

Net effect on a machine with two browsers, or one browser and a dev build: the first one to connect wins. Everything else is refused, forever, silently.

A first connection writes the pin, a second browser is refused with a dropped socket and no way to clear the pin

The one-way door

Nobody would have connected that symptom to that cause. The bug report would have been "the extension stopped working after I installed it on my laptop," and the answer would have been a settings table I never showed anyone.

The panel said "Vodou is not running," and Vodou was running

Here is the part that turned a bad failure into a lie.

The refusal was socket.destroy(), called before the WebSocket handshake completed. That is the tidy way to reject: you never allocate the connection. But from the extension's side, a TCP connection dropped during the upgrade is indistinguishable from a server that is not listening. The panel had exactly one story for that, and it told it: Vodou is not running.

So a user whose bridge was pinned to their other browser was told to restart a service. Restarting cannot fix a pin. They would restart, watch it fail, restart again, and file a bug describing a startup problem that did not exist.

The pair-code path had already solved this, and I had not noticed the shape was reusable. That path completes the handshake, then closes with a custom code (4403), and the background script reads the code and renders a specific line. So the pin path got its own: close 4404, not the pinned browser.

Completing a handshake in order to refuse someone feels like widening the door. It is not, and the reason is worth stating precisely: the function that attaches command handling to a socket is never called on that path. The socket can never carry a command. It exists long enough to carry a reason, then closes.

// ws.ts, the refusal
if (originPin && origin !== originPin) {
  try { ws.close(4404, 'not the pinned browser'); } catch { socket.destroy(); }
  return;
}
Enter fullscreen mode Exit fullscreen mode

The extension side stops hammering on 4404, re-probes on the same 20 second cadence as pairing (un-pairing is exactly the thing someone does while that panel is open), and the panel renders the pinned case above both "not running" and "pairing required," because a pinned gateway is running and no code pasted into that box will help.

bridge_ext_id held a bare ID, and two other readers were prefixing it

The obvious fix for the Firefox gap was to widen the stored value from a bare Chrome ID to a full origin, so moz-extension://… could be represented.

That would have broken two things I did not write and did not remember. Two other consumers read that same setting and prefix it with chrome-extension:// themselves: the frame-ancestors CSP header, and an origin check on the console server. Widening the value in place would have emitted a CSP header allowlisting an origin that does not exist, which fails in a browser, at render time, far from this code.

So the new value went into a new setting, bridge_ext_origin, holding the whole origin including scheme. The old key keeps its exact old meaning. The comparison prefers the new setting and falls back to the legacy bare ID when it is absent, so upgrading an install that genuinely meant to be pinned does not silently un-pin it.

And the pin itself is now gated: it applies only when the operator has deliberately turned pairing on, resolved through the same precedence the bridge already used for that setting (env override, then stored setting). Pairing is off by default. With it off, the shape check on the origin is the floor.

Before: unconditional pin, no visibility, no undo, dropped socket. After: pin only when pairing is on, visible in the bridge card, an unpair control, and a close code the panel explains

The property: every automatically written trust value has a reader and an eraser

Not "be careful with pinning." Here is the checkable version:

If your code writes a trust decision that no human typed, then some surface must display the stored value, and some surface must clear it, and the refusal it causes must be distinguishable at the client from the service being down. Three things. It is true or false of a given codebase and you can go and look.

Mine was false on all three, and each miss made the others invisible. The write was automatic, so nobody knew a value existed. There was no display, so nobody could see which browser had won. There was no eraser, so knowing would not have helped. And the refusal was byte-identical to an outage, so the one diagnostic signal pointed at the wrong subsystem.

Run this against your own auto-trust rows, in about five minutes

Nothing here is Vodou-specific. Three checks.

1. List the trust values your code writes without a human. Grep your settings or devices writes for the ones with no request handler behind them:

grep -rn "setSetting\|UPDATE settings\|INSERT INTO devices" src/ \
  | grep -viE "routes/|api/|handlers/|admin"
Enter fullscreen mode Exit fullscreen mode

Every hit is a value your system decided on its own. For each one, ask: what happens to the second caller?

2. Ask your database which of those are populated on a machine nobody configured.

SELECT key, value, updated_at FROM settings
WHERE key LIKE '%_id' OR key LIKE '%_origin' OR key LIKE '%token%'
ORDER BY updated_at DESC;
Enter fullscreen mode Exit fullscreen mode

Passing: every row here corresponds to something a user could point at in your UI. Failing: a row whose key you have to explain, holding a value you cannot get rid of without UPDATE. That row is a support ticket waiting for a date.

3. Refuse yourself and watch what the client sees. Connect from a wrong origin and check whether you get a close frame or a dead socket:

node -e '
const WebSocket = require("ws");
const ws = new WebSocket("ws://127.0.0.1:PORT/bridge", {
  headers: { Origin: "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }
});
ws.on("close", (c, r) => console.log("CLOSE", c, String(r)));
ws.on("error", e => console.log("ERROR", e.code || e.message));
'
Enter fullscreen mode Exit fullscreen mode

Passing output is a line starting CLOSE with a code in the 4000-4999 range and a reason string your client already branches on. Failing output is ERROR ECONNRESET, or ERROR ECONNREFUSED, which is the same thing your client prints when the server is not running at all. If you get the failing output, your users cannot tell a policy refusal from an outage, and neither can you from a bug report.

Lightpanda and xNet stop at the 403, not at socket.destroy()

The published work on this is good on the pin and silent on the exit. The Lightpanda post is thorough about deriving and comparing the ID, and about the unpacked-build trap, and it ends at "you get a 403 and the endpoint never runs." The xNet bridge hardening PR gets one thing exactly right that I want to steal: /health stays unauthenticated so detection still works before pairing. That is the same instinct as my 4404, arrived at from the other direction: keep one channel that can still say something true when the authenticated one refuses.

The clearest confirmation that this failure class is real is in an ElizaOS fix for a client pinned to the wrong loopback port, landing on a "Pairing is not enabled on this server" dead end, where auto-recovery skipped it because the recovery predicate treated every loopback base as healthy. Different system, same shape: a correct-looking guard produced a terminal state with no exit, and the recovery path could not see it.

Still live: the pin is one value, so two browsers cannot both be trusted

I deliberately did not fix this. The stored pin is a scalar. If you turn pairing on and you use two browsers, you are choosing one of them, and switching means an explicit un-pair.

The right answer is a set, and there is a good model for it in alpi's per-device profile scope, which stores a list, treats empty as unrestricted for back-compat, and uses a sentinel for malformed on-disk values specifically so that a broken store cannot widen permissions. That last detail is the one to copy: the failure direction of a corrupt trust record should be closed, not open.

I am holding that until somebody actually turns pairing on. Building a set before anyone uses the scalar is how you end up maintaining two representations of a feature with no users. The un-pair control had to exist first, because until it did, the honest instruction for a locked-out user was "open a SQL client."


Source: A trust pin with no undo is an outage you shipped on purpose by Chad Priest, from Building Vodou in Public.

Top comments (0)