@mulmobridge/chat-service 0.1.6 → 0.1.7

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.
@@ -22,6 +22,15 @@ export interface ChatStateStore {
22
22
  connectSession(transportId: string, externalChatId: string, chatSessionId: string, roleId?: string): Promise<TransportChatState | null>;
23
23
  generateSessionId(transportId: string, externalChatId: string): string;
24
24
  }
25
+ /** True iff `sessionId` is safe to persist into transport state and later
26
+ * hand to session-metadata / event-log readers on the host side. Adds an
27
+ * explicit `..` rejection on top of `isSafeId` — the safe-id character
28
+ * class alone would accept the literal `..` and let a state file written
29
+ * by `/connect` poison downstream commands (e.g. `/history` reading the
30
+ * poisoned sessionId back through `readSessionJsonl`). Applied at the
31
+ * `/connect` route entry AND inside `connectSession` as defense-in-depth
32
+ * (issue #1896 follow-up to #1888 / #1895). */
33
+ export declare function isSafeSessionId(sessionId: string): boolean;
25
34
  export declare function createChatStateStore(opts: {
26
35
  transportsDir: string;
27
36
  logger: Logger;
@@ -15,6 +15,23 @@ import { writeFileAtomic } from "./atomic-write.js";
15
15
  function isSafeId(id) {
16
16
  return /^[\w.-]+$/.test(id) && id.length > 0 && id.length <= 200;
17
17
  }
18
+ /** True iff `sessionId` is safe to persist into transport state and later
19
+ * hand to session-metadata / event-log readers on the host side. Adds an
20
+ * explicit `..` rejection on top of `isSafeId` — the safe-id character
21
+ * class alone would accept the literal `..` and let a state file written
22
+ * by `/connect` poison downstream commands (e.g. `/history` reading the
23
+ * poisoned sessionId back through `readSessionJsonl`). Applied at the
24
+ * `/connect` route entry AND inside `connectSession` as defense-in-depth
25
+ * (issue #1896 follow-up to #1888 / #1895). */
26
+ export function isSafeSessionId(sessionId) {
27
+ if (typeof sessionId !== "string")
28
+ return false;
29
+ if (!isSafeId(sessionId))
30
+ return false;
31
+ if (sessionId.includes(".."))
32
+ return false;
33
+ return true;
34
+ }
18
35
  // ── Factory ──────────────────────────────────────────────────
19
36
  export function createChatStateStore(opts) {
20
37
  const { transportsDir, logger } = opts;
@@ -58,6 +75,18 @@ export function createChatStateStore(opts) {
58
75
  return state;
59
76
  };
60
77
  const connectSession = async (transportId, externalChatId, chatSessionId, roleId) => {
78
+ // Defense-in-depth: even though the /connect route validates chatSessionId
79
+ // at entry, refuse to persist an unsafe value here too. Otherwise a caller
80
+ // that bypasses the route (test harness, alternate transport, direct store
81
+ // access) could still write a hostile sessionId into the state file — and
82
+ // downstream commands like /history would later read that back into
83
+ // path-traversing filesystem operations. Return null so the route surfaces
84
+ // it as 404 (same as "no state for this chat"); either way the caller
85
+ // can't succeed with a hostile input. Issue #1896.
86
+ if (!isSafeSessionId(chatSessionId)) {
87
+ logger.warn("chat-state", "refused to connect unsafe sessionId", { transportId, externalChatId });
88
+ return null;
89
+ }
61
90
  const existing = await getChatState(transportId, externalChatId);
62
91
  if (!existing)
63
92
  return null;
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  // standalone npm package without internal edits. See #269 / #305.
18
18
  import { Router } from "express";
19
19
  import { CHAT_SERVICE_ROUTES } from "@mulmobridge/protocol";
20
- import { createChatStateStore } from "./chat-state.js";
20
+ import { createChatStateStore, isSafeSessionId } from "./chat-state.js";
21
21
  import { createCommandHandler } from "./commands.js";
22
22
  import { createRelay } from "./relay.js";
23
23
  import { createPushQueue } from "./push-queue.js";
@@ -97,6 +97,17 @@ export function createChatService(deps) {
97
97
  badRequest(res, "chatSessionId is required");
98
98
  return;
99
99
  }
100
+ // Reject hostile / malformed sessionIds at the entry so they can't be
101
+ // persisted into transport state. Without this gate a caller could POST
102
+ // `{"chatSessionId": "../../etc/x"}`, the value would land in the state
103
+ // file, and a later `/history` command would read it back and hand it to
104
+ // `readSessionJsonl` — whose backing reader is documented as "internal
105
+ // fixed paths only, no `..` traversal guard". Also defended inside
106
+ // `connectSession` for defense-in-depth (issue #1896 follow-up to #1895).
107
+ if (!isSafeSessionId(chatSessionId)) {
108
+ badRequest(res, "chatSessionId has an unsafe format");
109
+ return;
110
+ }
100
111
  // Resolve the target session's role BEFORE calling connectSession so the
101
112
  // persisted state's `roleId` tracks the new session's role — otherwise the
102
113
  // next relay's `startChat` would resume the new session under the previous
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/chat-service",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Server-side chat service for MulmoBridge — socket.io + REST bridge to Claude Code agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",