Skip to content

Commit c1eedff

Browse files
authored
security: server-side gate — password moves to GATE_PASSWORD env (#6)
The password was hardcoded in app.js (const === '1234') — anyone reading the asset had the secret, and the check was client-side only, so the WebSocket was reachable without solving the gate at all. Now: - agent-bridge reads GATE_PASSWORD from env; unset = open bridge (loopback/local use, previous behavior) - POST /auth validates the password and returns a per-run random token; the secret exists only in the process env - WebSocket upgrades must carry ?token= when a password is set; rejects with close 1008 - app.js: unlock() exchanges the password via /auth, stores the token, attaches it to the WS URL; a 1008 close clears the token and re-shows the gate (no reconnect loop) - '1234' and the chat-pass localStorage flag are gone from the codebase entirely
1 parent df64bf0 commit c1eedff

2 files changed

Lines changed: 62 additions & 18 deletions

File tree

frontend/agent-bridge.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,29 @@ const html = await Bun.file(new URL("./index.html", import.meta.url)).text();
127127
const css = await Bun.file(new URL("./style.css", import.meta.url)).text();
128128
const js = await Bun.file(new URL("./app.js", import.meta.url)).text();
129129

130+
const GATE_PASSWORD = process.env.GATE_PASSWORD || "";
131+
const RUN_TOKEN = crypto.randomUUID();
130132
Bun.serve({
131133
port: PORT,
132134
hostname: "127.0.0.1",
133135
async fetch(req, server) {
134-
// WebSocket upgrade first
135-
if (server.upgrade(req)) return;
136+
const url = new URL(req.url);
137+
const path = url.pathname;
138+
139+
// Server-side gate: the password lives only in this process's env.
140+
// POST /auth exchanges it for a per-run token; WS upgrades must
141+
// carry that token when GATE_PASSWORD is set. No password
142+
// configured → bridge is open (loopback/local use).
143+
if (path === "/auth") {
144+
const body = await req.json().catch(() => ({ password: "" }));
145+
const ok = !GATE_PASSWORD || body.password === GATE_PASSWORD;
146+
if (ok) return Response.json({ ok: true, token: RUN_TOKEN });
147+
return Response.json({ ok: false }, { status: 401 });
148+
}
149+
150+
// WebSocket upgrade — token attached as ?token=
151+
if (server.upgrade(req, { data: { token: url.searchParams.get("token") } })) return;
136152

137-
const path = new URL(req.url).pathname;
138153
if (path === "/" || path === "/index.html") {
139154
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
140155
}
@@ -152,9 +167,13 @@ Bun.serve({
152167

153168
websocket: {
154169
open(ws: ClientWebSocket) {
170+
// Gate: reject upgrades without the run token (close 1008 → the
171+
// client shows the password prompt instead of reconnect-looping)
172+
if (GATE_PASSWORD && ws.data?.token !== RUN_TOKEN) {
173+
ws.close(1008, "unauthorized");
174+
return;
175+
}
155176
clients.add(ws);
156-
ws.activeSession = null;
157-
console.log(`[bridge] client connected (${clients.size})`);
158177

159178
// Reconnect (page refresh) → reattach to the most recent session;
160179
// only create one when none exist.

frontend/app.js

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,35 @@ function saveNick(v) {
9292
localStorage.setItem('chat-nick', nick);
9393
}
9494

95-
// ── 4. Password gate (R3) ────────────────────────────────────────────
96-
97-
function unlock() {
98-
if (gateInput.value === '1234') {
99-
gateEl.style.display = 'none';
100-
localStorage.setItem('chat-pass', '1');
101-
input.focus();
102-
} else {
103-
gateInput.value = '';
104-
gateInput.placeholder = 'wrong';
95+
// ── 4. Password gate (R3) — server-side ─────────────────────────────
96+
// No password lives in this file: POST /auth exchanges it for a per-run
97+
// token which rides the WS URL. A 1008 close = token rejected → show
98+
// the gate again instead of reconnect-looping.
99+
100+
async function unlock() {
101+
gateInput.disabled = true;
102+
try {
103+
const res = await fetch('/auth', {
104+
method: 'POST',
105+
headers: { 'Content-Type': 'application/json' },
106+
body: JSON.stringify({ password: gateInput.value }),
107+
});
108+
const data = await res.json().catch(() => ({ ok: false }));
109+
if (data.ok && data.token) {
110+
localStorage.setItem('chat-token', data.token);
111+
gateEl.style.display = 'none';
112+
input.focus();
113+
connect();
114+
} else {
115+
gateInput.value = '';
116+
gateInput.placeholder = 'wrong';
117+
}
118+
} catch {
119+
gateInput.placeholder = 'server unreachable';
120+
} finally {
121+
gateInput.disabled = false;
105122
}
106123
}
107-
if (localStorage.getItem('chat-pass') === '1') gateEl.style.display = 'none';
108124

109125
// ── 5. Sidebar / sessions (R2) ───────────────────────────────────────
110126

@@ -748,12 +764,14 @@ function handleEvent(msg) {
748764

749765
function connect() {
750766
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
751-
ws = new WebSocket(proto + '//' + location.host);
767+
const token = localStorage.getItem('chat-token');
768+
ws = new WebSocket(proto + '//' + location.host + (token ? '?token=' + encodeURIComponent(token) : ''));
752769

753770
ws.onopen = () => {
754771
dot.classList.add('on');
755772
input.disabled = false;
756773
sendBtn.disabled = false;
774+
gateEl.style.display = 'none'; // connected → authenticated (or no gate)
757775
clearTimeout(reconnectTimer);
758776
// The bridge reattaches refreshes to the latest session (R2); on a
759777
// first-ever connect it auto-creates one.
@@ -764,10 +782,17 @@ function connect() {
764782
}
765783
};
766784

767-
ws.onclose = () => {
785+
ws.onclose = (e) => {
768786
dot.classList.remove('on');
769787
input.disabled = true;
770788
sendBtn.disabled = true;
789+
if (e.code === 1008) {
790+
// Token rejected — show the gate; no reconnect loop
791+
localStorage.removeItem('chat-token');
792+
gateEl.style.display = 'flex';
793+
gateInput.focus();
794+
return;
795+
}
771796
reconnectTimer = setTimeout(connect, 2000);
772797
};
773798

0 commit comments

Comments
 (0)