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 +1 -1
- package/server/backends/google.ts +2 -2
- package/server/backends/remoteHost/index.ts +1 -1
- package/server/backends/remoteHost/routes.ts +2 -2
- package/server/config/env.ts +13 -0
- package/server/files/dirRequest.ts +6 -2
- package/server/files/open-dir.ts +1 -1
- package/server/files/pick-file.ts +2 -2
- package/server/git/gitRemote.ts +1 -1
- package/server/git/worktree-routes.ts +5 -5
- package/server/index.ts +11 -2
- package/server/infra/allowed-origin.ts +26 -5
- package/server/infra/loopback.ts +40 -0
- package/server/infra/pubsub.ts +6 -3
- package/server/infra/tmux-routes.ts +3 -3
- package/server/routes/app-routes.ts +7 -1
- package/server/routes/same-origin-guard.ts +42 -0
- package/server/routes/ws-routes.ts +2 -2
- package/server/session/command-summary.ts +2 -2
package/package.json
CHANGED
|
@@ -54,7 +54,7 @@ const liveDeps: GoogleRouteDeps = {
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
interface GoogleRouteOptions {
|
|
57
|
-
isAllowedOrigin: (origin
|
|
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
|
|
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
|
|
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
|
}
|
package/server/config/env.ts
CHANGED
|
@@ -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(
|
|
11
|
-
|
|
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
|
}
|
package/server/files/open-dir.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function openCommand(platform: NodeJS.Platform): string {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
interface OpenDirOptions {
|
|
15
|
-
isAllowedOrigin: (origin
|
|
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
|
|
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"] });
|
package/server/git/gitRemote.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function resolveGithubUrl(dir: string): Promise<string | null> {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
interface GitRemoteOptions {
|
|
53
|
-
isAllowedOrigin: (origin
|
|
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
|
|
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
|
-
|
|
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,
|
|
6
|
-
// host on any port is allowed, which is what
|
|
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
|
-
|
|
18
|
-
|
|
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
|
+
}
|
package/server/infra/pubsub.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|