ssh-mcp 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.
@@ -28,25 +28,34 @@ const READ_ONLY_ALLOWLIST = new Set([
28
28
  * pattern was narrower). Anything here is also destructive for classification
29
29
  * purposes; see DESTRUCTIVE_DENYLIST below.
30
30
  */
31
+ /*
32
+ * Every `\s+` here is followed by a literal. Where a `.*` or `[^|]*` comes
33
+ * next, the quantifier before it is a single `\s`: `\s+.*` lets both halves
34
+ * claim the same run of spaces, so a non-matching command is retried from every
35
+ * split and the match goes quadratic. sanitizeCommand caps commands at
36
+ * profile.maxChars (5000 by default) long before this runs, which is what keeps
37
+ * that cheap — but a policy check should not depend on a limit set three layers
38
+ * away and configurable to any value.
39
+ */
31
40
  const FORBIDDEN_PATTERNS = [
32
41
  /rm\s+-rf?\s+\/(\s|$)/, // rm -rf / — the filesystem root itself
33
42
  /mkfs\./,
34
- /dd\s+.*\bof=\/dev\//,
43
+ /dd\s.*\bof=\/dev\//,
35
44
  />\s*\/dev\/sd/,
36
45
  /\bshutdown\b/,
37
46
  /\breboot\b/,
38
47
  /\bhalt\b/,
39
48
  /\bpoweroff\b/,
40
49
  /:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
41
- /curl\s+.*\|\s*(sh|bash|zsh)/,
42
- /wget\s+.*\|\s*(sh|bash|zsh)/,
50
+ /curl\s[^|]*\|\s*(sh|bash|zsh)/,
51
+ /wget\s[^|]*\|\s*(sh|bash|zsh)/,
43
52
  /\beval\b/,
44
53
  />\s*\/etc\/cron/,
45
54
  />\s*\/etc\/systemd/,
46
55
  />\s*~\/.ssh\/authorized_keys/,
47
56
  /\biptables\s+-F\b/,
48
57
  /\bchmod\s+-R\s+777\s+\//,
49
- /\bchown\s+-R\s+.*\s+\/\s*$/,
58
+ /\bchown\s+-R\s.*\s\/\s*$/,
50
59
  ];
51
60
  /**
52
61
  * Commands that are destructive but legitimate under approval — e.g.
@@ -1,7 +1,11 @@
1
1
  import { createHash } from 'crypto';
2
2
  export function fingerprintPublicKey(key) {
3
3
  const hash = createHash('sha256').update(key).digest('base64');
4
- return `SHA256:${hash.replace(/=+$/, '')}`;
4
+ // `={0,2}` rather than `=+`: base64 padding is never longer than two
5
+ // characters, and the bounded form cannot backtrack. Not reachable here — the
6
+ // input is always a 44-character digest — but an unbounded trailing
7
+ // quantifier is the shape worth not copying elsewhere.
8
+ return `SHA256:${hash.replace(/={0,2}$/, '')}`;
5
9
  }
6
10
  export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
7
11
  if (mode === 'insecure')
@@ -1,8 +1,45 @@
1
+ import { randomBytes } from 'crypto';
1
2
  import { tracer } from '../observability/tracer.js';
2
3
  const ANSI_REGEX = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\r/g;
3
4
  export function stripAnsi(text) {
4
5
  return text.replace(ANSI_REGEX, '');
5
6
  }
7
+ /**
8
+ * Trim leading and trailing newlines by index rather than with /^\n+/ and
9
+ * /\n+$/.
10
+ *
11
+ * The trailing pattern is unanchored at the start, so on output that is mostly
12
+ * newlines but does not end in one the engine retries `\n+` from every offset:
13
+ * quadratic in the length of the output. That output is whatever the remote
14
+ * command printed and the session buffer holds up to 2 MB of it, which measured
15
+ * at roughly 25 minutes of blocked event loop — and the loop is shared by every
16
+ * session and connection this server has open.
17
+ */
18
+ /**
19
+ * A per-command marker separating the command's own output from the trailer
20
+ * carrying `$?` and `$PWD`.
21
+ *
22
+ * Guessing one is enough to forge an exit code or a working directory — a
23
+ * failed command reported as successful, in a server whose whole claim is an
24
+ * auditable record of what ran. Every marker is written to the remote host in
25
+ * the clear, so anything watching that session collects a stream of them, and
26
+ * Math.random() is reconstructible from such a stream.
27
+ *
28
+ * base64url keeps the value safe both inside the single-quoted printf that
29
+ * emits it and inside the RegExp built from it: no quotes, no metacharacters.
30
+ */
31
+ export function generateSessionMarker() {
32
+ return randomBytes(12).toString('base64url');
33
+ }
34
+ export function trimNewlines(text) {
35
+ let start = 0;
36
+ let end = text.length;
37
+ while (start < end && text.charCodeAt(start) === 10)
38
+ start++;
39
+ while (end > start && text.charCodeAt(end - 1) === 10)
40
+ end--;
41
+ return start === 0 && end === text.length ? text : text.slice(start, end);
42
+ }
6
43
  export class Session {
7
44
  id;
8
45
  name;
@@ -143,7 +180,7 @@ export class InteractiveSession extends Session {
143
180
  : cleaned;
144
181
  const endIdx = afterBegin.indexOf(endMarker);
145
182
  const between = endIdx >= 0 ? afterBegin.slice(0, endIdx) : afterBegin;
146
- const output = between.replace(/^\n+/, '').replace(/\n+$/, '');
183
+ const output = trimNewlines(between);
147
184
  if (reportedCwd)
148
185
  this.cwd = reportedCwd;
149
186
  this.touch();
@@ -205,7 +242,7 @@ export class InteractiveSession extends Session {
205
242
  });
206
243
  }
207
244
  generateMarker() {
208
- return Math.random().toString(36).substring(2, 14) + Date.now().toString(36);
245
+ return generateSessionMarker();
209
246
  }
210
247
  async close() {
211
248
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.0.0",
4
+ "version": "2.0.1",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "type": "module",
7
7
  "bin": {