claude-flow 3.63.3 → 3.63.5
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/.claude/proven-config.json +1 -1
- package/.claude-plugin/marketplace.json +3 -3
- package/README.md +1 -1
- package/node_modules/@claude-flow/mcp/dist/transport/websocket.d.ts +2 -0
- package/node_modules/@claude-flow/mcp/dist/transport/websocket.d.ts.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/transport/websocket.js +28 -3
- package/node_modules/@claude-flow/mcp/dist/transport/websocket.js.map +1 -1
- package/node_modules/@claude-flow/mcp/package.json +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/README.md +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/discovery-service.d.ts +8 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/discovery-service.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/discovery-service.js +11 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/discovery-service.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/handshake-service.d.ts +4 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/handshake-service.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/handshake-service.js +16 -3
- package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/handshake-service.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js +17 -4
- package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/transport/midstream-aware-loader.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/transport/midstream-aware-loader.js +3 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/transport/midstream-aware-loader.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/package.json +1 -1
- package/package.json +5 -5
- package/v3/@claude-flow/cli/dist/src/commands/hooks.js +1 -1
- package/v3/@claude-flow/cli/dist/src/commands/mcp.js +12 -1
- package/v3/@claude-flow/cli/dist/src/commands/status.js +2 -1
- package/v3/@claude-flow/cli/dist/src/mcp-server.d.ts +9 -0
- package/v3/@claude-flow/cli/dist/src/mcp-server.js +23 -6
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentbbs-federation.js +8 -2
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentbbs-tools.js +10 -2
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.js +1 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/http-fetch-tools.d.ts +1 -1
- package/v3/@claude-flow/cli/dist/src/mcp-tools/http-fetch-tools.js +95 -9
- package/v3/@claude-flow/cli/dist/src/mcp-tools/system-tools.js +1 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/task-tools.js +3 -1
- package/v3/@claude-flow/cli/dist/src/mcp-tools/workflow-tools.js +3 -1
- package/v3/@claude-flow/cli/dist/src/mods/claude-installs.js +108 -3
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.d.ts +3 -1
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.js +5 -3
- package/v3/@claude-flow/cli/package.json +5 -5
- package/v3/@claude-flow/shared/dist/mcp/transport/guard.d.ts +17 -0
- package/v3/@claude-flow/shared/dist/mcp/transport/guard.js +43 -0
- package/v3/@claude-flow/shared/dist/mcp/transport/http.d.ts +2 -0
- package/v3/@claude-flow/shared/dist/mcp/transport/http.js +15 -1
- package/v3/@claude-flow/shared/dist/mcp/transport/websocket.d.ts +6 -0
- package/v3/@claude-flow/shared/dist/mcp/transport/websocket.js +24 -1
- package/v3/@claude-flow/shared/package.json +1 -1
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* (a 2.1.107 npm-global beside a current native one, say) silently decides.
|
|
8
8
|
*/
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
|
-
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
11
|
-
import { delimiter, join } from 'node:path';
|
|
10
|
+
import { closeSync, existsSync, fstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
11
|
+
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
12
12
|
export const MODS_DEFAULT_ON = '2.1.287';
|
|
13
13
|
export const MODS_FIRST = '2.1.277';
|
|
14
14
|
const parse = (v) => v.split('.').map((n) => Number(n));
|
|
@@ -52,6 +52,98 @@ export const defaultVersionOf = (path) => {
|
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
54
|
};
|
|
55
|
+
/** Known npm/cmd-shim Node launcher forms (legacy basedir and basedir_win).
|
|
56
|
+
* Match the whole template, not comments or arbitrary shell code. Unknown npm
|
|
57
|
+
* versions/formats simply keep the independent probe result. Never execute it.
|
|
58
|
+
*/
|
|
59
|
+
const npmCmdShim = (entry, modern) => [
|
|
60
|
+
'@ECHO off', 'GOTO start', ':find_dp0', 'SET dp0=%~dp0', 'EXIT /b', ':start',
|
|
61
|
+
'SETLOCAL', 'CALL :find_dp0', '', 'IF EXIST "%dp0%\\node.exe" (',
|
|
62
|
+
' SET "_prog=%dp0%\\node.exe"', ') ELSE (', ' SET "_prog=node"',
|
|
63
|
+
...(!modern ? [' SET PATHEXT=%PATHEXT:;.JS;=;%'] : []), ')', '',
|
|
64
|
+
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & '
|
|
65
|
+
+ (modern ? 'set PATHEXT=%PATHEXT:;.JS;=;% & ' : '')
|
|
66
|
+
+ `"%_prog%" "%dp0%\\${entry.replaceAll('/', '\\')}" %*`, '',
|
|
67
|
+
].join('\r\n');
|
|
68
|
+
const npmPosixShim = (entry, modern) => [
|
|
69
|
+
'#!/bin/sh', 'basedir=$(dirname "$(echo "$0" | sed -e \'s,\\\\,/,g\')")',
|
|
70
|
+
...(modern ? ['basedir_win="$basedir"'] : []), '',
|
|
71
|
+
modern ? 'case `uname -a` in' : 'case `uname` in',
|
|
72
|
+
...(modern ? [
|
|
73
|
+
' *CYGWIN*|*MINGW*|*MSYS*)', ' if command -v cygpath > /dev/null 2>&1; then',
|
|
74
|
+
' basedir_win=`cygpath -w "$basedir"`', ' fi', ' ;;', ' *WSL2*)',
|
|
75
|
+
' if command -v wslpath > /dev/null 2>&1; then',
|
|
76
|
+
' basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"',
|
|
77
|
+
' if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then',
|
|
78
|
+
' echo "Error: wslpath failed to convert path. WSL environment may be misconfigured." >&2',
|
|
79
|
+
' exit 1', ' fi', ' fi', ' ;;',
|
|
80
|
+
] : [
|
|
81
|
+
' *CYGWIN*|*MINGW*|*MSYS*)', ' if command -v cygpath > /dev/null 2>&1; then',
|
|
82
|
+
' basedir=`cygpath -w "$basedir"`', ' fi', ' ;;',
|
|
83
|
+
]), 'esac', '',
|
|
84
|
+
...(modern ? [
|
|
85
|
+
'PROG_EXE="$basedir/node.exe"', 'if ! [ -x "$PROG_EXE" ]; then',
|
|
86
|
+
' PROG_EXE="$basedir/node"', ' if ! [ -x "$PROG_EXE" ]; then',
|
|
87
|
+
' PROG_EXE=node', ' if ! [ -x "$PROG_EXE" ]; then', ' PROG_EXE=node.exe',
|
|
88
|
+
' fi', ' fi', 'fi', '', `exec "$PROG_EXE" "$basedir_win/${entry}" "$@"`,
|
|
89
|
+
] : [
|
|
90
|
+
'if [ -x "$basedir/node" ]; then', ` exec "$basedir/node" "$basedir/${entry}" "$@"`,
|
|
91
|
+
'else ', ` exec node "$basedir/${entry}" "$@"`, 'fi',
|
|
92
|
+
]), '',
|
|
93
|
+
].join('\n');
|
|
94
|
+
// A no-shebang/native bin uses npm's direct-exec forms, not a Node wrapper.
|
|
95
|
+
const npmDirectCmdShim = (entry) => [
|
|
96
|
+
'@ECHO off', 'GOTO start', ':find_dp0', 'SET dp0=%~dp0', 'EXIT /b', ':start',
|
|
97
|
+
'SETLOCAL', 'CALL :find_dp0', `"%dp0%\\${entry.replaceAll('/', '\\')}" %*`, '',
|
|
98
|
+
].join('\r\n');
|
|
99
|
+
const npmDirectPosixShim = (entry, modern) => {
|
|
100
|
+
const nodeShim = npmPosixShim(entry, modern);
|
|
101
|
+
const preamble = nodeShim.slice(0, nodeShim.indexOf(modern ? 'PROG_EXE=' : 'if [ -x'));
|
|
102
|
+
return preamble + `exec "$basedir/${entry}" "$@"\n`;
|
|
103
|
+
};
|
|
104
|
+
function npmShimTarget(path, cmd) {
|
|
105
|
+
let fd;
|
|
106
|
+
try {
|
|
107
|
+
fd = openSync(path, 'r');
|
|
108
|
+
const stat = fstatSync(fd);
|
|
109
|
+
if (!stat.isFile() || stat.size > 64 * 1024)
|
|
110
|
+
return null;
|
|
111
|
+
// Also cap the actual read if a file grows after stat.
|
|
112
|
+
const bytes = Buffer.alloc(64 * 1024 + 1);
|
|
113
|
+
let size = 0;
|
|
114
|
+
while (size < bytes.length) {
|
|
115
|
+
const n = readSync(fd, bytes, size, bytes.length - size, null);
|
|
116
|
+
if (!n)
|
|
117
|
+
break;
|
|
118
|
+
size += n;
|
|
119
|
+
}
|
|
120
|
+
if (size > 64 * 1024)
|
|
121
|
+
return null;
|
|
122
|
+
const text = bytes.subarray(0, size).toString('utf8').replace(/\r\n/g, '\n').trimEnd();
|
|
123
|
+
const targets = cmd
|
|
124
|
+
? [...text.matchAll(/"%dp0%\\([^"\r\n]+)" +%\*$/gm)].map(m => m[1].replaceAll('\\', '/'))
|
|
125
|
+
: [...text.matchAll(/^\s*exec (?:[^\r\n]* )?"\$basedir(?:_win)?\/([^"\r\n]+)" +"\$@"$/gm)].map(m => m[1]);
|
|
126
|
+
const entry = targets[0];
|
|
127
|
+
if (!entry || targets.some(t => t !== entry) || !/^[a-zA-Z0-9@._ /-]+$/.test(entry) || entry.startsWith('/'))
|
|
128
|
+
return null;
|
|
129
|
+
const template = cmd ? npmCmdShim : npmPosixShim;
|
|
130
|
+
const normalize = (value) => value.replace(/\r\n/g, '\n').trimEnd();
|
|
131
|
+
const node = [false, true].some(modern => normalize(template(entry, modern)) === text);
|
|
132
|
+
const direct = cmd ? normalize(npmDirectCmdShim(entry)) === text
|
|
133
|
+
: [false, true].some(modern => normalize(npmDirectPosixShim(entry, modern)) === text);
|
|
134
|
+
if (!node && !direct)
|
|
135
|
+
return null;
|
|
136
|
+
const target = realpathSync(resolve(dirname(path), entry));
|
|
137
|
+
return statSync(target).isFile() ? { path: target, mode: node ? 'node' : 'direct' } : null;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
if (fd !== undefined)
|
|
144
|
+
closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
55
147
|
/** Every `claude` on PATH, in PATH order (the first is what runs), plus ~/.local/bin. */
|
|
56
148
|
export function findClaudeInstalls(env, home, versionOf = defaultVersionOf) {
|
|
57
149
|
const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd', 'claude'] : ['claude'];
|
|
@@ -59,6 +151,7 @@ export function findClaudeInstalls(env, home, versionOf = defaultVersionOf) {
|
|
|
59
151
|
const seen = new Set();
|
|
60
152
|
const installs = [];
|
|
61
153
|
for (const dir of dirs) {
|
|
154
|
+
let cmd;
|
|
62
155
|
for (const name of names) {
|
|
63
156
|
const path = join(dir, name);
|
|
64
157
|
let real;
|
|
@@ -73,7 +166,19 @@ export function findClaudeInstalls(env, home, versionOf = defaultVersionOf) {
|
|
|
73
166
|
if (seen.has(real))
|
|
74
167
|
continue;
|
|
75
168
|
seen.add(real);
|
|
76
|
-
|
|
169
|
+
let version = versionOf(path);
|
|
170
|
+
if (process.platform === 'win32' && name === 'claude' && version === null && cmd?.version) {
|
|
171
|
+
const target = npmShimTarget(path, false);
|
|
172
|
+
const cmdTarget = npmShimTarget(cmd.path, true);
|
|
173
|
+
if (target && cmdTarget && target.path === cmdTarget.path && target.mode === cmdTarget.mode)
|
|
174
|
+
version = cmd.version;
|
|
175
|
+
}
|
|
176
|
+
const install = { path, version };
|
|
177
|
+
installs.push(install);
|
|
178
|
+
// Only a .cmd actually probed in this directory can supply a version.
|
|
179
|
+
// A realpath-deduplicated alias in another directory proves nothing here.
|
|
180
|
+
if (name === 'claude.cmd')
|
|
181
|
+
cmd = install;
|
|
77
182
|
}
|
|
78
183
|
}
|
|
79
184
|
return installs;
|
|
@@ -428,7 +428,9 @@ export declare class WorkerDaemon extends EventEmitter {
|
|
|
428
428
|
/**
|
|
429
429
|
* Manually trigger a worker
|
|
430
430
|
*/
|
|
431
|
-
triggerWorker(type: WorkerType
|
|
431
|
+
triggerWorker(type: WorkerType, opts?: {
|
|
432
|
+
manualTrigger?: boolean;
|
|
433
|
+
}): Promise<WorkerResult>;
|
|
432
434
|
/**
|
|
433
435
|
* Enable/disable a worker
|
|
434
436
|
*/
|
|
@@ -952,7 +952,7 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
952
952
|
}
|
|
953
953
|
try {
|
|
954
954
|
this.log('info', `Dequeued ${trigger}${workerId ? ` (id=${workerId})` : ''} from MCP dispatch queue`);
|
|
955
|
-
await this.triggerWorker(trigger);
|
|
955
|
+
await this.triggerWorker(trigger, { manualTrigger: false });
|
|
956
956
|
}
|
|
957
957
|
catch (err) {
|
|
958
958
|
this.log('warn', `Queued worker ${trigger} failed: ${err.message}`);
|
|
@@ -1926,7 +1926,7 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
1926
1926
|
/**
|
|
1927
1927
|
* Manually trigger a worker
|
|
1928
1928
|
*/
|
|
1929
|
-
async triggerWorker(type) {
|
|
1929
|
+
async triggerWorker(type, opts = {}) {
|
|
1930
1930
|
const workerConfig = this.config.workers.find(w => w.type === type);
|
|
1931
1931
|
if (!workerConfig) {
|
|
1932
1932
|
throw new Error(`Unknown worker type: ${type}`);
|
|
@@ -1941,7 +1941,9 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
1941
1941
|
// #2661 root-fix — an explicit manual trigger bypasses the repository-
|
|
1942
1942
|
// supervisor gate (still budget/dedup-gated) — see runWorkerLogic()'s
|
|
1943
1943
|
// doc comment.
|
|
1944
|
-
|
|
1944
|
+
// Only an operator's own `daemon trigger` is manual. A file in daemon-queue/ can come from a
|
|
1945
|
+
// cloned repo, so queue runs pass `manualTrigger: false` and stay behind the supervisor gate.
|
|
1946
|
+
return this.executeWorker(workerConfig, { manualTrigger: opts.manualTrigger ?? true });
|
|
1945
1947
|
}
|
|
1946
1948
|
/**
|
|
1947
1949
|
* Enable/disable a worker
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.63.
|
|
3
|
+
"version": "3.63.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|
|
@@ -102,11 +102,11 @@
|
|
|
102
102
|
"@electron/asar": "^4.3.1",
|
|
103
103
|
"@claude-flow/cli-core": "3.7.5",
|
|
104
104
|
"@claude-flow/codex": "3.0.5",
|
|
105
|
-
"@claude-flow/mcp": "3.1.
|
|
106
|
-
"@claude-flow/neural": "3.0.
|
|
107
|
-
"@claude-flow/plugin-agent-federation": "1.0.
|
|
105
|
+
"@claude-flow/mcp": "3.1.4",
|
|
106
|
+
"@claude-flow/neural": "3.0.1",
|
|
107
|
+
"@claude-flow/plugin-agent-federation": "1.0.3",
|
|
108
108
|
"@claude-flow/security": "3.0.5",
|
|
109
|
-
"@claude-flow/shared": "3.0.
|
|
109
|
+
"@claude-flow/shared": "3.0.6",
|
|
110
110
|
"@iarna/toml": "^2.2.5",
|
|
111
111
|
"@noble/ed25519": "2.3.0",
|
|
112
112
|
"@ruvector/rabitq-wasm": "0.1.0",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks for transports that carry no bearer token. A browser always sends an
|
|
3
|
+
* `Origin` header and cannot be stopped by CORS on a WebSocket upgrade, and a
|
|
4
|
+
* rebinding page reaches 127.0.0.1 under its own hostname, so an open loopback
|
|
5
|
+
* server must look at both. Non-browser clients send no `Origin`.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage } from 'node:http';
|
|
8
|
+
export declare function isLoopbackName(host: string): boolean;
|
|
9
|
+
/** Whether the `Host` header names this server (a loopback name or the bound host). */
|
|
10
|
+
export declare function hostHeaderAllowed(header: string | undefined, boundHost: string): boolean;
|
|
11
|
+
/** Whether a request's `Origin` may talk to an unauthenticated server: none at all, or one listed by name (`*` does not count). */
|
|
12
|
+
export declare function originAllowed(origin: string | undefined, allowed: readonly string[] | undefined): boolean;
|
|
13
|
+
/** Why an unauthenticated request must be refused, or `undefined` when it may proceed. */
|
|
14
|
+
export declare function refuseOpenRequest(req: Pick<IncomingMessage, 'headers'>, boundHost: string, allowedOrigins: readonly string[] | undefined): string | undefined;
|
|
15
|
+
/** Refuse to listen beyond loopback with no token unless the operator said so. */
|
|
16
|
+
export declare function assertSafeBind(host: string, authenticated: boolean, allowUnauthenticated: boolean | undefined): void;
|
|
17
|
+
//# sourceMappingURL=guard.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '0:0:0:0:0:0:0:1']);
|
|
2
|
+
export function isLoopbackName(host) {
|
|
3
|
+
return LOOPBACK.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ''));
|
|
4
|
+
}
|
|
5
|
+
/** The hostname of a `Host` header value: no port, no IPv6 brackets, lower case. */
|
|
6
|
+
function hostnameOf(header) {
|
|
7
|
+
const value = header.trim().toLowerCase();
|
|
8
|
+
const bracket = value.match(/^\[([^\]]*)\](?::\d*)?$/);
|
|
9
|
+
if (bracket)
|
|
10
|
+
return bracket[1];
|
|
11
|
+
return value.replace(/:\d*$/, '');
|
|
12
|
+
}
|
|
13
|
+
/** Whether the `Host` header names this server (a loopback name or the bound host). */
|
|
14
|
+
export function hostHeaderAllowed(header, boundHost) {
|
|
15
|
+
if (!header)
|
|
16
|
+
return false;
|
|
17
|
+
const name = hostnameOf(header);
|
|
18
|
+
return isLoopbackName(name) || name === boundHost.trim().toLowerCase().replace(/^\[|\]$/g, '');
|
|
19
|
+
}
|
|
20
|
+
/** Whether a request's `Origin` may talk to an unauthenticated server: none at all, or one listed by name (`*` does not count). */
|
|
21
|
+
export function originAllowed(origin, allowed) {
|
|
22
|
+
if (origin === undefined || origin === '')
|
|
23
|
+
return true;
|
|
24
|
+
return (allowed ?? []).some((entry) => entry !== '*' && entry === origin);
|
|
25
|
+
}
|
|
26
|
+
/** Why an unauthenticated request must be refused, or `undefined` when it may proceed. */
|
|
27
|
+
export function refuseOpenRequest(req, boundHost, allowedOrigins) {
|
|
28
|
+
// Rebinding only reaches a loopback bind; behind a proxy on another address the Host is the proxy's.
|
|
29
|
+
if (isLoopbackName(boundHost) && !hostHeaderAllowed(req.headers.host, boundHost))
|
|
30
|
+
return 'Host header does not name this server';
|
|
31
|
+
const origin = req.headers.origin;
|
|
32
|
+
if (!originAllowed(Array.isArray(origin) ? origin[0] : origin, allowedOrigins))
|
|
33
|
+
return 'cross-origin requests are refused without a token';
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
/** Refuse to listen beyond loopback with no token unless the operator said so. */
|
|
37
|
+
export function assertSafeBind(host, authenticated, allowUnauthenticated) {
|
|
38
|
+
if (!isLoopbackName(host) && !authenticated && allowUnauthenticated !== true) {
|
|
39
|
+
throw new Error(`Refusing to listen on non-loopback host "${host}" without authentication: every registered tool would be reachable from the network. ` +
|
|
40
|
+
'Configure a token, bind to a loopback host, or set allowUnauthenticated to accept the risk.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=guard.js.map
|
|
@@ -25,6 +25,8 @@ export interface HttpTransportConfig {
|
|
|
25
25
|
corsEnabled?: boolean;
|
|
26
26
|
corsOrigins?: string[];
|
|
27
27
|
auth?: AuthConfig;
|
|
28
|
+
/** Accept a non-loopback bind with no token (the operator's own risk). */
|
|
29
|
+
allowUnauthenticated?: boolean;
|
|
28
30
|
maxRequestSize?: string;
|
|
29
31
|
requestTimeout?: number;
|
|
30
32
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { EventEmitter } from 'events';
|
|
15
15
|
import express from 'express';
|
|
16
16
|
import { createServer } from 'http';
|
|
17
|
+
import { assertSafeBind, refuseOpenRequest } from './guard.js';
|
|
17
18
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
18
19
|
import cors from 'cors';
|
|
19
20
|
import helmet from 'helmet';
|
|
@@ -56,12 +57,14 @@ export class HttpTransport extends EventEmitter {
|
|
|
56
57
|
host: this.config.host,
|
|
57
58
|
port: this.config.port,
|
|
58
59
|
});
|
|
60
|
+
assertSafeBind(this.config.host, this.config.auth?.enabled === true, this.config.allowUnauthenticated);
|
|
59
61
|
// Create HTTP server
|
|
60
62
|
this.server = createServer(this.app);
|
|
61
|
-
// Create WebSocket server
|
|
63
|
+
// Create WebSocket server (CORS does not apply to upgrades, so a token-less server checks Host and Origin itself)
|
|
62
64
|
this.wss = new WebSocketServer({
|
|
63
65
|
server: this.server,
|
|
64
66
|
path: '/ws',
|
|
67
|
+
verifyClient: (info) => this.config.auth?.enabled === true || refuseOpenRequest(info.req, this.config.host, this.config.corsOrigins) === undefined,
|
|
65
68
|
});
|
|
66
69
|
this.setupWebSocketHandlers();
|
|
67
70
|
// Start server
|
|
@@ -159,6 +162,17 @@ export class HttpTransport extends EventEmitter {
|
|
|
159
162
|
* Setup Express middleware
|
|
160
163
|
*/
|
|
161
164
|
setupMiddleware() {
|
|
165
|
+
// A server with no token must not answer a web page or a rebound hostname.
|
|
166
|
+
if (this.config.auth?.enabled !== true) {
|
|
167
|
+
this.app.use((req, res, next) => {
|
|
168
|
+
const why = refuseOpenRequest(req, this.config.host, this.config.corsOrigins);
|
|
169
|
+
if (why === undefined) {
|
|
170
|
+
next();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
res.status(403).json({ jsonrpc: '2.0', id: null, error: { code: -32000, message: `Forbidden: ${why}` } });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
162
176
|
// Security headers
|
|
163
177
|
this.app.use(helmet({
|
|
164
178
|
contentSecurityPolicy: false, // Allow for flexibility
|
|
@@ -26,6 +26,10 @@ export interface WebSocketTransportConfig {
|
|
|
26
26
|
heartbeatTimeout?: number;
|
|
27
27
|
maxMessageSize?: number;
|
|
28
28
|
auth?: AuthConfig;
|
|
29
|
+
/** Accept a non-loopback bind with no token. */
|
|
30
|
+
allowUnauthenticated?: boolean;
|
|
31
|
+
/** Browser origins allowed to connect, by exact name. */
|
|
32
|
+
allowedOrigins?: string[];
|
|
29
33
|
enableBinaryMode?: boolean;
|
|
30
34
|
}
|
|
31
35
|
/**
|
|
@@ -107,6 +111,8 @@ export declare class WebSocketTransport extends EventEmitter implements ITranspo
|
|
|
107
111
|
/**
|
|
108
112
|
* Handle incoming message
|
|
109
113
|
*/
|
|
114
|
+
/** Token from `Authorization: Bearer` or `?token=`, compared in constant time. */
|
|
115
|
+
private upgradeHasValidToken;
|
|
110
116
|
private handleMessage;
|
|
111
117
|
/**
|
|
112
118
|
* Parse incoming message
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { EventEmitter } from 'events';
|
|
15
15
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
16
16
|
import { createServer } from 'http';
|
|
17
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
import { assertSafeBind, refuseOpenRequest } from './guard.js';
|
|
17
19
|
/**
|
|
18
20
|
* WebSocket Transport Implementation
|
|
19
21
|
*/
|
|
@@ -51,6 +53,7 @@ export class WebSocketTransport extends EventEmitter {
|
|
|
51
53
|
port: this.config.port,
|
|
52
54
|
path: this.config.path || '/ws',
|
|
53
55
|
});
|
|
56
|
+
assertSafeBind(this.config.host, this.config.auth?.enabled === true, this.config.allowUnauthenticated);
|
|
54
57
|
// Create HTTP server for WebSocket upgrade
|
|
55
58
|
this.server = createServer((req, res) => {
|
|
56
59
|
// Simple HTTP response for non-WebSocket requests
|
|
@@ -63,6 +66,9 @@ export class WebSocketTransport extends EventEmitter {
|
|
|
63
66
|
path: this.config.path || '/ws',
|
|
64
67
|
maxPayload: this.config.maxMessageSize || 10 * 1024 * 1024,
|
|
65
68
|
perMessageDeflate: true, // Enable compression
|
|
69
|
+
verifyClient: (info) => this.config.auth?.enabled === true
|
|
70
|
+
? this.upgradeHasValidToken(info.req)
|
|
71
|
+
: refuseOpenRequest(info.req, this.config.host, this.config.allowedOrigins) === undefined,
|
|
66
72
|
});
|
|
67
73
|
this.setupWebSocketHandlers();
|
|
68
74
|
this.startHeartbeat();
|
|
@@ -225,7 +231,8 @@ export class WebSocketTransport extends EventEmitter {
|
|
|
225
231
|
lastActivity: new Date(),
|
|
226
232
|
messageCount: 0,
|
|
227
233
|
isAlive: true,
|
|
228
|
-
|
|
234
|
+
// With a token configured the upgrade itself was authenticated (verifyClient).
|
|
235
|
+
isAuthenticated: true,
|
|
229
236
|
};
|
|
230
237
|
this.clients.set(clientId, client);
|
|
231
238
|
this.totalConnections++;
|
|
@@ -264,6 +271,22 @@ export class WebSocketTransport extends EventEmitter {
|
|
|
264
271
|
/**
|
|
265
272
|
* Handle incoming message
|
|
266
273
|
*/
|
|
274
|
+
/** Token from `Authorization: Bearer` or `?token=`, compared in constant time. */
|
|
275
|
+
upgradeHasValidToken(req) {
|
|
276
|
+
const header = req.headers.authorization;
|
|
277
|
+
const fromHeader = typeof header === 'string' ? header.match(/^Bearer\s+(.+)$/i)?.[1] : undefined;
|
|
278
|
+
const fromQuery = new URL(req.url ?? '', 'http://localhost').searchParams.get('token') ?? undefined;
|
|
279
|
+
const given = fromHeader ?? fromQuery;
|
|
280
|
+
if (!given)
|
|
281
|
+
return false;
|
|
282
|
+
const digest = (v) => createHash('sha256').update(v, 'utf8').digest();
|
|
283
|
+
let ok = false;
|
|
284
|
+
for (const valid of this.config.auth?.tokens ?? []) {
|
|
285
|
+
if (timingSafeEqual(digest(given), digest(valid)))
|
|
286
|
+
ok = true;
|
|
287
|
+
}
|
|
288
|
+
return ok;
|
|
289
|
+
}
|
|
267
290
|
async handleMessage(client, data) {
|
|
268
291
|
client.lastActivity = new Date();
|
|
269
292
|
client.messageCount++;
|