mulmoterminal 2.0.0 → 2.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmoterminal",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "MulmoTerminal — browser terminal + GUI panel for Claude Code. One command to start.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,7 +54,7 @@ const liveDeps: GoogleRouteDeps = {
54
54
  };
55
55
 
56
56
  interface GoogleRouteOptions {
57
- isAllowedOrigin: (origin?: string) => boolean;
57
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
58
58
  }
59
59
 
60
60
  const failed = (res: Response, cause: unknown, fallback: string): void => {
@@ -81,7 +81,7 @@ async function readStatus(deps: GoogleRouteDeps): Promise<GoogleStatus> {
81
81
  // it, any site the user visits could POST /unlink and silently drop their account link.
82
82
  export function mountGoogleRoutes(app: Express, { isAllowedOrigin }: GoogleRouteOptions, deps: GoogleRouteDeps = liveDeps): void {
83
83
  const forbidden = (req: Request, res: Response): boolean => {
84
- if (isAllowedOrigin(req.headers.origin)) return false;
84
+ if (isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return false;
85
85
  res.status(403).json({ error: "forbidden origin" });
86
86
  return true;
87
87
  };
@@ -93,7 +93,7 @@ export function initRemoteHostBackend(deps: RemoteHostBackendDeps): void {
93
93
  }
94
94
 
95
95
  export interface RemoteHostRouteOptions {
96
- isAllowedOrigin: (origin?: string) => boolean;
96
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
97
97
  }
98
98
 
99
99
  export function mountRemoteHostRoutes(app: Express, { isAllowedOrigin }: RemoteHostRouteOptions): void {
@@ -28,7 +28,7 @@ interface ErrorResponse {
28
28
  type RemoteHostResponse = StatusResponse | ErrorResponse;
29
29
 
30
30
  export interface RemoteHostRouteDeps {
31
- isAllowedOrigin: (origin?: string) => boolean;
31
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
32
32
  getLifecycle: () => RemoteHostLifecycle | null;
33
33
  exportSession: () => string | null;
34
34
  // 401 for a genuinely expired/invalid blob (client drops it), 5xx for a transient
@@ -53,7 +53,7 @@ export function mountRemoteHostRoutes(app: Express, deps: RemoteHostRouteDeps):
53
53
  // Origin-guarded (loopback only) + not-initialized guard. Returns the live lifecycle
54
54
  // (already sent the error response and returns null when either guard fails).
55
55
  const guard = (req: Request, res: Response<RemoteHostResponse>): RemoteHostLifecycle | null => {
56
- if (!deps.isAllowedOrigin(req.headers.origin)) {
56
+ if (!deps.isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) {
57
57
  res.status(403).json({ error: "forbidden origin" });
58
58
  return null;
59
59
  }
@@ -6,6 +6,19 @@ import path from "node:path";
6
6
 
7
7
  export const PORT = process.env.PORT || 34567;
8
8
 
9
+ // The interface the HTTP server binds to. LOOPBACK BY DEFAULT — this server has no
10
+ // authentication of its own: every /api route, the terminal WebSockets, and the routes that
11
+ // spawn a PTY are reachable by anyone who can open a socket to it. Binding every interface
12
+ // (Node's default when `listen()` is given no host) put all of that on the local network.
13
+ //
14
+ // It is also what several guards silently depend on: `isAllowedOrigin` trusts a request with
15
+ // no Origin header, on the grounds that such a caller is a local CLI or MCP tool rather than
16
+ // a browser. That reasoning only holds while nothing remote can connect at all.
17
+ //
18
+ // Set MULMOTERMINAL_HOST to widen it deliberately — `0.0.0.0` to accept from anywhere, or a
19
+ // specific address. Only on a network you trust, and knowing the above.
20
+ export const BIND_HOST = process.env.MULMOTERMINAL_HOST || "127.0.0.1";
21
+
9
22
  // The workspace used as the PTY cwd and as the root for persisted session state. index.ts
10
23
  // creates it at boot before anything spawns into it.
11
24
  export const CLAUDE_CWD = process.env.CLAUDE_CWD || path.join(os.homedir(), "mulmoclaude");
@@ -7,8 +7,12 @@ import { isRecord } from "../../common/isRecord.js";
7
7
  // absolute, existing. Returns the directory, or null after sending the matching
8
8
  // error response — so the caller just does `const dir = resolveDirRequest(...); if (!dir) return;`.
9
9
  // Shared by the local-only /api/open-dir and /api/git-remote routes.
10
- export function resolveDirRequest(req: Request, res: Response, isAllowedOrigin: (origin?: string) => boolean): string | null {
11
- if (!isAllowedOrigin(req.headers.origin)) {
10
+ export function resolveDirRequest(
11
+ req: Request,
12
+ res: Response,
13
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean,
14
+ ): string | null {
15
+ if (!isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) {
12
16
  res.status(403).json({ error: "forbidden origin" });
13
17
  return null;
14
18
  }
@@ -12,7 +12,7 @@ export function openCommand(platform: NodeJS.Platform): string {
12
12
  }
13
13
 
14
14
  interface OpenDirOptions {
15
- isAllowedOrigin: (origin?: string) => boolean;
15
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
16
16
  }
17
17
 
18
18
  // POST /api/open-dir { path } — reveal an absolute, existing directory in the OS
@@ -55,7 +55,7 @@ export function parsePickerOutput(stdout: string): string[] {
55
55
  }
56
56
 
57
57
  interface PickFileOptions {
58
- isAllowedOrigin: (origin?: string) => boolean;
58
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
59
59
  }
60
60
 
61
61
  // POST /api/pick-file — open the OS file dialog and return the chosen absolute
@@ -64,7 +64,7 @@ interface PickFileOptions {
64
64
  // { paths: [] }. Same-origin guarded like the other local-action routes.
65
65
  export function mountPickFileRoute(app: Express, { isAllowedOrigin }: PickFileOptions) {
66
66
  app.post("/api/pick-file", (req: Request, res) => {
67
- if (!isAllowedOrigin(req.headers.origin)) return res.status(403).json({ error: "forbidden origin" });
67
+ if (!isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return res.status(403).json({ error: "forbidden origin" });
68
68
  const directory = isRecord(req.body) && req.body.directory === true;
69
69
  const { cmd, args } = pickFileCommand(process.platform, directory);
70
70
  const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -50,7 +50,7 @@ export function resolveGithubUrl(dir: string): Promise<string | null> {
50
50
  }
51
51
 
52
52
  interface GitRemoteOptions {
53
- isAllowedOrigin: (origin?: string) => boolean;
53
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
54
54
  }
55
55
 
56
56
  // POST /api/git-remote { path } -> { githubUrl: string | null }. Lets the browser
@@ -8,7 +8,7 @@ import { worktreeDiff } from "./worktree-diff.js";
8
8
  import { pushWorktree, createOrOpenPR } from "./worktree-pr.js";
9
9
 
10
10
  interface WorktreeRouteOptions {
11
- isAllowedOrigin: (origin?: string) => boolean;
11
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
12
12
  }
13
13
 
14
14
  // A failed git/gh command is a 500; a precondition the user can fix (no remote, not
@@ -41,7 +41,7 @@ export function mountWorktreeRoutes(app: Express, { isAllowedOrigin }: WorktreeR
41
41
  });
42
42
 
43
43
  app.post("/api/worktrees/create", async (req, res) => {
44
- if (!isAllowedOrigin(req.headers.origin)) return res.status(403).end();
44
+ if (!isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return res.status(403).end();
45
45
  const { repoDir, task } = req.body ?? {};
46
46
  if (typeof repoDir !== "string" || typeof task !== "string" || !task.trim()) {
47
47
  return res.status(400).json({ error: "repoDir and a non-empty task are required" });
@@ -55,7 +55,7 @@ export function mountWorktreeRoutes(app: Express, { isAllowedOrigin }: WorktreeR
55
55
  // re-confirms with `force`; not-managed → a bad path), 500 for an internal git
56
56
  // failure the client can't fix by retrying.
57
57
  app.post("/api/worktrees/remove", async (req, res) => {
58
- if (!isAllowedOrigin(req.headers.origin)) return res.status(403).end();
58
+ if (!isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return res.status(403).end();
59
59
  const { repoDir, path: worktreePath, deleteBranch, force } = req.body ?? {};
60
60
  if (typeof repoDir !== "string" || typeof worktreePath !== "string") {
61
61
  return res.status(400).json({ error: "repoDir and path are required" });
@@ -70,7 +70,7 @@ export function mountWorktreeRoutes(app: Express, { isAllowedOrigin }: WorktreeR
70
70
 
71
71
  // Push the worktree's branch to origin (the first half of "取り込み").
72
72
  app.post("/api/worktrees/push", async (req, res) => {
73
- if (!isAllowedOrigin(req.headers.origin)) return res.status(403).end();
73
+ if (!isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return res.status(403).end();
74
74
  const { cwd } = req.body ?? {};
75
75
  if (typeof cwd !== "string") return res.status(400).json({ error: "cwd is required" });
76
76
  const result = await pushWorktree(cwd);
@@ -80,7 +80,7 @@ export function mountWorktreeRoutes(app: Express, { isAllowedOrigin }: WorktreeR
80
80
  // Push, then create a PR via gh — or fall back to the GitHub compare URL. The body
81
81
  // returns the URL for the client to open and which path produced it (`via`).
82
82
  app.post("/api/worktrees/pr", async (req, res) => {
83
- if (!isAllowedOrigin(req.headers.origin)) return res.status(403).end();
83
+ if (!isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return res.status(403).end();
84
84
  const { cwd } = req.body ?? {};
85
85
  if (typeof cwd !== "string") return res.status(400).json({ error: "cwd is required" });
86
86
  const result = await createOrOpenPR(cwd);
package/server/index.ts CHANGED
@@ -25,7 +25,8 @@ import {
25
25
  import { sandboxEnabled, sandboxPlatformSupported, dockerAvailable, ensureSandboxImage } from "./infra/sandbox.js";
26
26
  import { isAllowedOrigin } from "./infra/allowed-origin.js";
27
27
  import { serverErrorExit } from "./infra/server-exit.js";
28
- import { PORT, CLAUDE_CWD, MULMOTERMINAL_HOME, SESSION_ID_RE } from "./config/env.js";
28
+ import { PORT, BIND_HOST, CLAUDE_CWD, MULMOTERMINAL_HOME, SESSION_ID_RE } from "./config/env.js";
29
+ import { isLoopbackBinding } from "./infra/loopback.js";
29
30
  import { messageOf } from "./errors.js";
30
31
  import { hookSettingsJson } from "./session/hook-settings.js";
31
32
  import { mcpConfigJson } from "./session/mcp-config.js";
@@ -556,8 +557,16 @@ server.on("error", (err) => {
556
557
  process.exit(code);
557
558
  });
558
559
 
559
- server.listen(PORT, () => {
560
+ // Number(): PORT comes from the environment as a string, and the (port, host, cb) overload
561
+ // takes a number — the (port, cb) form we used before accepted either.
562
+ server.listen(Number(PORT), BIND_HOST, () => {
560
563
  console.log(`mulmoterminal running at http://localhost:${PORT}`);
564
+ if (!isLoopbackBinding(server.address())) {
565
+ console.warn(
566
+ `\x1b[33m[security]\x1b[0m bound to ${BIND_HOST}, not loopback — this server has no authentication, ` +
567
+ `so anyone who can reach ${BIND_HOST}:${PORT} can read your sessions and start terminals. Unset MULMOTERMINAL_HOST to bind 127.0.0.1.`,
568
+ );
569
+ }
561
570
  const surviving = tmuxAvailable() ? tmuxListSessionIds() : [];
562
571
  if (tmuxAvailable()) {
563
572
  const detail = surviving.length ? ` — ${surviving.length} session(s) survived; reattach on connect` : "";
@@ -1,9 +1,14 @@
1
1
  // Which browser origins may open this server's sockets and reach its privileged routes.
2
2
  //
3
3
  // Only same-machine browser origins, so a malicious website the user happens to visit can't
4
- // drive the local Claude PTY (a cross-site WebSocket hijack). A MISSING Origin is allowed
5
- // that is a non-browser local client, which cannot be a cross-site request. Any localhost
6
- // host on any port is allowed, which is what covers the Vite dev proxy.
4
+ // drive the local Claude PTY (a cross-site WebSocket hijack). A MISSING Origin is allowed when
5
+ // the peer is on this machine — that is a non-browser local client (CLI, MCP tool, curl), which
6
+ // cannot be a cross-site request. Any localhost host on any port is allowed, which is what
7
+ // covers the Vite dev proxy.
8
+ //
9
+ // The peer check matters because "it must be local" used to be inferred from the bind address
10
+ // alone. Nothing enforced that, and the server in fact listened on every interface, so a remote
11
+ // client sending no Origin was trusted outright.
7
12
  //
8
13
  // Out of index.ts because every route module and the pub/sub socket take this as a
9
14
  // dependency and every one of their tests passes a stub, so the real predicate — the single
@@ -12,10 +17,26 @@
12
17
  //
13
18
  // `hostname` is what `new URL()` normalises to, so an IPv6 literal arrives bracketed
14
19
  // (`[::1]`) however it was written, and a host is already lower-cased and punycoded.
20
+ import { isLoopbackAddress } from "./loopback.js";
21
+
15
22
  const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
16
23
 
17
- export function isAllowedOrigin(origin?: string): boolean {
18
- if (!origin) return true;
24
+ // `remoteAddress` is the socket's peer, and it is REQUIRED — not because every caller has one,
25
+ // but because a caller that forgets it silently falls back to trusting a missing Origin, which
26
+ // is the whole defect this parameter exists to close. A caller that genuinely cannot know (the
27
+ // socket.io CORS callback, which is handed no request) writes `undefined` at the call site,
28
+ // where the reader can see the gap instead of inferring it from an absent argument.
29
+ //
30
+ // A PRESENT Origin is still judged on the origin alone, peer or not. That is deliberate: the
31
+ // origin check defends against a BROWSER being driven cross-site, and a browser cannot forge
32
+ // the header. A non-browser that forges it must already be able to reach the port, which only
33
+ // happens when the operator widened the bind — an explicit decision to trust whoever can
34
+ // connect. Making a non-loopback peer fail outright here would break the one setup that opt-in
35
+ // exists for (a container or WSL forwarding a port, where the peer is the bridge).
36
+ export function isAllowedOrigin(origin: string | undefined, remoteAddress: string | undefined): boolean {
37
+ // No Origin means a non-browser caller — a CLI, an MCP tool, curl. Trustworthy only because
38
+ // it is on this machine; a remote one is precisely what this must not wave through.
39
+ if (!origin) return remoteAddress === undefined || isLoopbackAddress(remoteAddress);
19
40
  try {
20
41
  return LOOPBACK_HOSTNAMES.has(new URL(origin).hostname);
21
42
  } catch {
@@ -0,0 +1,40 @@
1
+ // Is a peer address on this machine?
2
+ //
3
+ // The one thing several guards ultimately rest on. The server binds to loopback by default
4
+ // (see BIND_HOST in config/env.ts), and code that treats a request as trusted because
5
+ // "remote traffic can't reach us" is only correct while that holds — a deployment that opts
6
+ // into a wider bind, or any proxy in front, breaks the assumption silently. Asking the socket
7
+ // is the only answer that stays true either way.
8
+ //
9
+ // Node reports an IPv4 peer on a dual-stack listener as `::ffff:127.0.0.1`, so the mapped
10
+ // form is unwrapped before comparing: matching the bare literals alone would classify a real
11
+ // loopback client as remote. The whole 127.0.0.0/8 block is loopback, not just 127.0.0.1.
12
+ export function isLoopbackAddress(address: string | undefined | null): boolean {
13
+ if (!address) return false;
14
+ const bare = address.startsWith("::ffff:") ? address.slice("::ffff:".length) : address;
15
+ if (bare === "::1" || bare === "0:0:0:0:0:0:0:1") return true;
16
+ return LOOPBACK_V4.test(bare);
17
+ }
18
+
19
+ // 127.0.0.0/8, with each octet actually in range — `127.999.0.1` is not an address. The peer
20
+ // form comes from the kernel and could not be out of range, but isLoopbackBindHost below runs
21
+ // this over a value the operator typed.
22
+ const OCTET = "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
23
+ const LOOPBACK_V4 = new RegExp(`^127\\.${OCTET}\\.${OCTET}\\.${OCTET}$`);
24
+
25
+ // Whether the server ended up listening only on this machine, from what the OS says it bound —
26
+ // `server.address()` — rather than from the string that was requested.
27
+ //
28
+ // Classifying the requested string cannot be made right: `localhost`, `127.1`, `127.0.1` and
29
+ // `127.000.000.001` are all valid ways to ask for loopback, and `localhost` can be pointed
30
+ // somewhere else entirely by a hosts file. Asking after the fact answers all of them, because
31
+ // the kernel has already resolved whatever was typed. A warning that fires on a safe setting
32
+ // trains people to ignore the one that matters, so it has to be exact.
33
+ //
34
+ // A string address is a pipe or UNIX socket — no network exposure. A null means the server is
35
+ // not listening, so there is nothing to warn about yet.
36
+ export function isLoopbackBinding(address: string | { address: string } | null): boolean {
37
+ if (address === null) return true;
38
+ if (typeof address === "string") return true;
39
+ return isLoopbackAddress(address.address);
40
+ }
@@ -12,16 +12,19 @@ export interface Publisher {
12
12
  publish(channel: string, data: unknown): void;
13
13
  }
14
14
 
15
- export function createPubSub(server: HttpServer, isAllowedOrigin: (origin?: string) => boolean = () => true) {
15
+ export function createPubSub(server: HttpServer, isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean = () => true) {
16
16
  const io = new IOServer(server, {
17
17
  path: "/ws/pubsub",
18
18
  transports: ["websocket"],
19
19
  // Reject cross-origin connections so an untrusted website can't subscribe to
20
20
  // session activity. allowRequest covers the websocket handshake; cors covers
21
21
  // any polling/preflight.
22
- allowRequest: (req, cb) => cb(null, isAllowedOrigin(req.headers.origin)),
22
+ allowRequest: (req, cb) => cb(null, isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)),
23
23
  cors: {
24
- origin: (origin, cb) => cb(null, isAllowedOrigin(origin)),
24
+ // Socket.IO hands this callback no request, so there is genuinely no peer to check —
25
+ // spelled out rather than omitted. allowRequest above gates the actual handshake and
26
+ // does see the socket, so this covers only polling/preflight.
27
+ origin: (origin, cb) => cb(null, isAllowedOrigin(origin, undefined)),
25
28
  credentials: true,
26
29
  },
27
30
  });
@@ -4,7 +4,7 @@ import type { Express } from "express";
4
4
  // orphan-selection boundary are unit-testable without booting the server (mirrors
5
5
  // gitRemote / open-dir / command-summary).
6
6
  export interface TmuxRouteDeps {
7
- isAllowedOrigin: (origin?: string) => boolean;
7
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
8
8
  isValidSessionId: (id: string) => boolean;
9
9
  // Reap a live session (kills its pty + tmux + cleanup); a no-op without a live entry.
10
10
  reapSession: (id: string) => void;
@@ -35,7 +35,7 @@ export function mountTmuxRoutes(app: Express, deps: TmuxRouteDeps): void {
35
35
  // leaving it for the disconnect grace. Works even when the WS is down, and kills a tmux
36
36
  // orphaned by a prior server restart (reap alone is a no-op without a live entry).
37
37
  app.post("/api/session/:id/terminate", (req, res) => {
38
- if (!deps.isAllowedOrigin(req.headers.origin)) return res.status(403).json({ error: "forbidden origin" });
38
+ if (!deps.isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return res.status(403).json({ error: "forbidden origin" });
39
39
  const id = req.params.id;
40
40
  if (!deps.isValidSessionId(id)) return res.status(400).json({ error: "invalid session id" });
41
41
  deps.reapSession(id); // live entry → kills pty + tmux + cleanup
@@ -47,7 +47,7 @@ export function mountTmuxRoutes(app: Express, deps: TmuxRouteDeps): void {
47
47
  // resumable (a persisted grid session, or a Claude/Codex transcript on disk). These
48
48
  // accumulate across server restarts, which the in-memory reap bookkeeping can't reach.
49
49
  app.post("/api/tmux/cleanup-orphans", async (req, res) => {
50
- if (!deps.isAllowedOrigin(req.headers.origin)) return res.status(403).json({ error: "forbidden origin" });
50
+ if (!deps.isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return res.status(403).json({ error: "forbidden origin" });
51
51
  const isResumable = await deps.resumablePredicate();
52
52
  const killed: string[] = [];
53
53
  for (const id of deps.listTmuxIds()) {
@@ -9,6 +9,7 @@
9
9
  // What arrives as deps is what index.ts owns: the spawners, the session lifecycle, the title
10
10
  // manager, the tool stores, and `publish` (pub/sub exists only once the HTTP server does).
11
11
  import path from "node:path";
12
+ import { sameOriginGuard } from "./same-origin-guard.js";
12
13
  import express, { type Express } from "express";
13
14
  import { mountAllRoutes } from "../infra/plugins-registry.js";
14
15
  import { mountConfigRoutes } from "../config/config-routes.js";
@@ -56,7 +57,7 @@ import { SPA_FALLBACK_RE } from "../infra/spa-fallback.js";
56
57
 
57
58
  export interface AppRouteDeps extends SessionActivityDeps {
58
59
  clientDir: string;
59
- isAllowedOrigin: (origin: string | undefined) => boolean;
60
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
60
61
  publish: (channel: string, data: unknown) => void;
61
62
  sessionChannel: (id: string) => string;
62
63
  toolStores: ReturnType<typeof createToolStores>;
@@ -75,6 +76,11 @@ export function mountAppRoutes(app: Express, deps: AppRouteDeps): void {
75
76
  const clientDir = deps.clientDir;
76
77
  app.use(express.json({ limit: "25mb" }));
77
78
 
79
+ // Before any route: one same-origin gate for every state-changing request, so a site the
80
+ // user visits cannot drive this server through their browser. Individual routes keep their
81
+ // own checks — this is the floor, not a replacement.
82
+ app.use(sameOriginGuard(deps.isAllowedOrigin));
83
+
78
84
  // The GUI-plugin tool routes this server answers itself: spawnBackgroundChat,
79
85
  // manageAccounting, manageCollection (routes/plugin-routes.ts). ALL of them must precede
80
86
  // mountAllRoutes' /api/plugin/:toolName catch-all below, which would otherwise take them.
@@ -0,0 +1,42 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+
3
+ // A single same-origin gate for every state-changing request, instead of each route
4
+ // remembering to ask.
5
+ //
6
+ // Why it is needed even though the server binds to loopback: loopback is reachable from the
7
+ // user's OWN browser, so a site they happen to visit can POST to http://localhost:34567. It
8
+ // cannot READ the reply cross-origin, but the side effect still happens — and among these
9
+ // routes are ones that spawn an agent, rewrite the config, and write files. Per-route guards
10
+ // covered some of that surface and not the rest, which is the failure mode a central gate
11
+ // removes: a route added tomorrow is covered by default rather than by memory.
12
+ //
13
+ // Safe methods are left alone — but not because they are guaranteed side-effect free. Gating
14
+ // them by origin would achieve nothing: a cross-site `<img src=http://localhost:.../>` sends NO
15
+ // Origin header, exactly like a legitimate local fetch, so the predicate cannot tell them
16
+ // apart. It would only break the media loads that cannot send a header.
17
+ //
18
+ // That makes "a GET must not change anything" a rule this file relies on and cannot enforce.
19
+ // One route already bends it: GET /api/session/:id calls freshenRosterTitle, which may spawn
20
+ // an LLM title generation. It is rate-limited and needs a server-generated session UUID the
21
+ // caller would have to know, so the cost of the bend is small — but a NEW side effect added to
22
+ // a GET is unprotected by anything here, and belongs in a POST.
23
+ const STATE_CHANGING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
24
+
25
+ // The one deliberate cross-origin caller: a custom collection view is LLM-authored HTML in a
26
+ // sandboxed iframe, so its origin is opaque (`null`) by construction. It carries its own
27
+ // HMAC-signed capability token, scoped to one slug and to read/write — a stronger check than
28
+ // origin, and the reason these paths must not be judged by origin at all.
29
+ const VIEW_DATA = /^\/api\/collections\/[^/]+\/view-data(\/|$)/;
30
+
31
+ export function needsSameOrigin(method: string, path: string): boolean {
32
+ if (!STATE_CHANGING.has(method.toUpperCase())) return false;
33
+ return !VIEW_DATA.test(path);
34
+ }
35
+
36
+ export function sameOriginGuard(isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean) {
37
+ return (req: Request, res: Response, next: NextFunction): void => {
38
+ if (!needsSameOrigin(req.method, req.path)) return next();
39
+ if (isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return next();
40
+ res.status(403).json({ error: "forbidden origin" });
41
+ };
42
+ }
@@ -38,7 +38,7 @@ export interface WsRouteDeps {
38
38
  /** The http server these endpoints hang their `upgrade` handler off. */
39
39
  server: Server;
40
40
  /** Only same-machine browser origins may open a terminal socket. */
41
- isAllowedOrigin: (origin: string | undefined) => boolean;
41
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
42
42
  claudeBin: string;
43
43
  setWaiting: (id: string, waiting: boolean) => void;
44
44
  reattachPty: (entry: PtyEntry, ws: WebSocket, sessionId: string) => PtyEntry;
@@ -369,7 +369,7 @@ export function mountTerminalWebSockets(deps: WsRouteDeps) {
369
369
  // socket.io is entitled to.
370
370
  if (!kind) return;
371
371
  const target = serverFor[kind];
372
- if (!deps.isAllowedOrigin(req.headers.origin)) {
372
+ if (!deps.isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) {
373
373
  console.warn(`[ws] rejected cross-origin upgrade from ${req.headers.origin}`);
374
374
  socket.destroy();
375
375
  return;
@@ -136,7 +136,7 @@ export async function summarizeLog(log: string, deps: SummarizeDeps = {}): Promi
136
136
  }
137
137
 
138
138
  interface CommandSummaryDeps {
139
- isAllowedOrigin: (origin?: string) => boolean;
139
+ isAllowedOrigin: (origin: string | undefined, remoteAddress: string | undefined) => boolean;
140
140
  }
141
141
 
142
142
  // POST /api/command/summarize { log } -> { summary, truncated }. Same-origin guarded
@@ -144,7 +144,7 @@ interface CommandSummaryDeps {
144
144
  // local `claude` binary. Mirrors mountPickFileRoute's shape.
145
145
  export function mountCommandSummaryRoute(app: Express, { isAllowedOrigin }: CommandSummaryDeps): void {
146
146
  app.post("/api/command/summarize", async (req: Request, res) => {
147
- if (!isAllowedOrigin(req.headers.origin)) return res.status(403).json({ error: "forbidden origin" });
147
+ if (!isAllowedOrigin(req.headers.origin, req.socket?.remoteAddress)) return res.status(403).json({ error: "forbidden origin" });
148
148
  const body = isRecord(req.body) ? req.body : {};
149
149
  if (typeof body.log !== "string") return res.status(400).json({ error: "body.log (string) required" });
150
150
  try {