@threadbase-sh/streamer 1.29.1 → 1.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -215,7 +215,7 @@ function verifySignature(rawBody, signature, secret) {
215
215
  }
216
216
  }
217
217
  function isWithinSkew(timestampHeader, skewSeconds) {
218
- if (!timestampHeader) return true;
218
+ if (!timestampHeader) return false;
219
219
  const t = Number(timestampHeader);
220
220
  if (!Number.isFinite(t)) return false;
221
221
  const now = Math.floor(Date.now() / 1e3);
@@ -2589,7 +2589,7 @@ function isLocalRequest(remoteAddr) {
2589
2589
  const addr = remoteAddr ?? "";
2590
2590
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
2591
2591
  }
2592
- var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz"]);
2592
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz", "/ws"]);
2593
2593
  var PUBLIC_POST_PATHS = /* @__PURE__ */ new Set(["/api/pair/exchange", "/api/__update"]);
2594
2594
  var PUBLIC_POST_PREFIXES = ["/internal/sessions/"];
2595
2595
  var authMiddleware = (deps) => async (c, next) => {
@@ -3052,14 +3052,16 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3052
3052
  const app = new import_hono10.Hono();
3053
3053
  app.get(
3054
3054
  "/ws",
3055
- upgradeWebSocket(() => {
3055
+ upgradeWebSocket((c) => {
3056
+ const key = c.req.query("key");
3057
+ const preAuthed = typeof key === "string" && validateApiKey(key, deps.apiKey);
3056
3058
  let openWs = null;
3057
3059
  return {
3058
3060
  onOpen(_evt, ws) {
3059
3061
  const raw = ws.raw;
3060
3062
  if (!raw) return;
3061
3063
  openWs = raw;
3062
- deps.handleWsOpen(raw);
3064
+ deps.handleWsOpen(raw, preAuthed);
3063
3065
  },
3064
3066
  onMessage(evt, _ws) {
3065
3067
  if (openWs) deps.handleWsMessage(openWs, evt.data);
@@ -4964,6 +4966,13 @@ function deriveProjectChatTitle(input) {
4964
4966
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
4965
4967
  }
4966
4968
 
4969
+ // src/services/questions/permissionAnswerKeys.ts
4970
+ var ANSWER_KEYS_ALLOWLIST = /^(?:\r|[yn]\r|\x03|\d+\r)$/;
4971
+ function sanitizeAnswerKeys(keys) {
4972
+ if (keys === void 0) return void 0;
4973
+ return ANSWER_KEYS_ALLOWLIST.test(keys) ? keys : void 0;
4974
+ }
4975
+
4967
4976
  // src/services/questions/detectAskUserQuestion.ts
4968
4977
  function normalizeContent2(raw) {
4969
4978
  if (Array.isArray(raw)) return raw;
@@ -5568,6 +5577,8 @@ var WSHub = class {
5568
5577
  var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project boundary: ${browseRoot}. Do not read, write, or execute commands that access files or directories outside this boundary.`;
5569
5578
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5570
5579
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5580
+ var DEFAULT_WS_AUTH_TIMEOUT_MS = 5e3;
5581
+ var WS_CLOSE_UNAUTHORIZED = 4401;
5571
5582
  var REFRESH_TTL_MS = 2e3;
5572
5583
  var START_READY_TIMEOUT_MS = 15e3;
5573
5584
  function parseIncludeAgentsEnv(raw) {
@@ -5658,6 +5669,14 @@ var StreamerServer = class {
5658
5669
  clientIdToWs = /* @__PURE__ */ new Map();
5659
5670
  // Reverse map for cleanup on close
5660
5671
  wsToClientId = /* @__PURE__ */ new Map();
5672
+ // M1 — WS auth. Sockets that have authenticated (via ?key= at upgrade OR a
5673
+ // { type: "auth", token } first message). Only authed sockets are added to
5674
+ // the hub and receive broadcasts.
5675
+ // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
5676
+ wsAuthed = /* @__PURE__ */ new Set();
5677
+ // Keyless sockets awaiting their first-message auth handshake → close timer.
5678
+ wsAuthPending = /* @__PURE__ */ new Map();
5679
+ wsAuthTimeoutMs;
5661
5680
  cache = null;
5662
5681
  projectsRepo = null;
5663
5682
  conversationsRepo = null;
@@ -5697,6 +5716,7 @@ var StreamerServer = class {
5697
5716
  this.scanProfiles = config.scanProfiles;
5698
5717
  this.codexRoots = config.codexRoots ?? [(0, import_path13.join)((0, import_os6.homedir)(), ".codex", "sessions")];
5699
5718
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
5719
+ this.wsAuthTimeoutMs = config.wsAuthTimeoutMs ?? DEFAULT_WS_AUTH_TIMEOUT_MS;
5700
5720
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5701
5721
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
5702
5722
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path13.join)((0, import_os6.homedir)(), ".threadbase", "cache");
@@ -5954,17 +5974,39 @@ var StreamerServer = class {
5954
5974
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
5955
5975
  handleBrowse: (url, res) => this.handleBrowse(url, res),
5956
5976
  handleMkdir: (req, res) => this.handleMkdir(req, res),
5957
- handleWsOpen: (ws) => {
5958
- this.wsHub.addClient(ws);
5959
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
5960
- ws.send(JSON.stringify({ type: "session_list", sessions }));
5961
- if (this.cacheReady) {
5962
- ws.send(JSON.stringify({ type: "cache_ready" }));
5977
+ handleWsOpen: (ws, preAuthed) => {
5978
+ if (preAuthed) {
5979
+ this.completeWsAuth(ws);
5980
+ return;
5963
5981
  }
5982
+ const timer = setTimeout(() => {
5983
+ this.wsAuthPending.delete(ws);
5984
+ try {
5985
+ ws.close(WS_CLOSE_UNAUTHORIZED, "auth timeout");
5986
+ } catch {
5987
+ }
5988
+ }, this.wsAuthTimeoutMs);
5989
+ this.wsAuthPending.set(ws, timer);
5964
5990
  },
5965
5991
  handleWsMessage: async (ws, raw) => {
5966
5992
  try {
5967
5993
  const msg = JSON.parse(String(raw));
5994
+ if (!this.wsAuthed.has(ws)) {
5995
+ if (msg.type === "auth" && typeof msg.token === "string") {
5996
+ const t = this.wsAuthPending.get(ws);
5997
+ if (t) clearTimeout(t);
5998
+ this.wsAuthPending.delete(ws);
5999
+ if (validateApiKey(msg.token, this.apiKey)) {
6000
+ this.completeWsAuth(ws);
6001
+ } else {
6002
+ try {
6003
+ ws.close(WS_CLOSE_UNAUTHORIZED, "unauthorized");
6004
+ } catch {
6005
+ }
6006
+ }
6007
+ }
6008
+ return;
6009
+ }
5968
6010
  if (msg.type === "register" && typeof msg.clientId === "string") {
5969
6011
  const oldClientId = this.wsToClientId.get(ws);
5970
6012
  if (oldClientId) this.clientIdToWs.delete(oldClientId);
@@ -5979,12 +6021,20 @@ var StreamerServer = class {
5979
6021
  }
5980
6022
  }
5981
6023
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
5982
- this.startGraceTimer(msg.sessionId, 0);
6024
+ if (this.sessionSubscribers.get(msg.sessionId)?.has(ws)) {
6025
+ this.startGraceTimer(msg.sessionId, 0);
6026
+ }
5983
6027
  }
5984
6028
  } catch {
5985
6029
  }
5986
6030
  },
5987
6031
  handleWsClose: (ws) => {
6032
+ const pendingTimer = this.wsAuthPending.get(ws);
6033
+ if (pendingTimer) {
6034
+ clearTimeout(pendingTimer);
6035
+ this.wsAuthPending.delete(ws);
6036
+ }
6037
+ this.wsAuthed.delete(ws);
5988
6038
  const clientId = this.wsToClientId.get(ws);
5989
6039
  if (clientId) {
5990
6040
  this.clientIdToWs.delete(clientId);
@@ -6054,6 +6104,20 @@ var StreamerServer = class {
6054
6104
  this.wsHub.broadcast(payload);
6055
6105
  }
6056
6106
  }
6107
+ // M1: finalize a WebSocket auth (via ?key= at upgrade or a first-message
6108
+ // handshake) — register it with the hub and send the initial snapshot. Only
6109
+ // authed sockets reach this, so no unauthenticated client ever receives a
6110
+ // broadcast.
6111
+ // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
6112
+ completeWsAuth(ws) {
6113
+ this.wsAuthed.add(ws);
6114
+ this.wsHub.addClient(ws);
6115
+ const sessions = this.sessionStore.list(this.ptyAttachedIds());
6116
+ ws.send(JSON.stringify({ type: "session_list", sessions }));
6117
+ if (this.cacheReady) {
6118
+ ws.send(JSON.stringify({ type: "cache_ready" }));
6119
+ }
6120
+ }
6057
6121
  addSessionSubscriber(sessionId, ws) {
6058
6122
  let subs = this.sessionSubscribers.get(sessionId);
6059
6123
  if (!subs) {
@@ -6340,6 +6404,9 @@ var StreamerServer = class {
6340
6404
  this.ptyManager.dispose();
6341
6405
  this.fileWatcher.dispose();
6342
6406
  this.wsHub.dispose();
6407
+ for (const timer of this.wsAuthPending.values()) clearTimeout(timer);
6408
+ this.wsAuthPending.clear();
6409
+ this.wsAuthed.clear();
6343
6410
  this.pairTokens.dispose();
6344
6411
  if (this.dbPool) {
6345
6412
  await this.dbPool.end();
@@ -7466,12 +7533,16 @@ var StreamerServer = class {
7466
7533
  return;
7467
7534
  }
7468
7535
  this.pendingPermission.set(sessionId, gate);
7536
+ const safeOptions = gate.options.map((o) => {
7537
+ const answerKeys = sanitizeAnswerKeys(o.answerKeys);
7538
+ return answerKeys === void 0 ? { index: o.index, label: o.label } : { ...o, answerKeys };
7539
+ });
7469
7540
  this.wsHub.broadcast({
7470
7541
  type: "permission",
7471
7542
  sessionId,
7472
7543
  ...gate.prompt ? { prompt: gate.prompt } : {},
7473
7544
  ...gate.detail ? { detail: gate.detail } : {},
7474
- options: gate.options,
7545
+ options: safeOptions,
7475
7546
  ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
7476
7547
  });
7477
7548
  }