ssh-mcp 2.14.0 → 2.15.0

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/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![Downloads](https://img.shields.io/npm/dm/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
5
5
  [![CI](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
6
6
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/tufantunc/ssh-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
7
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/15082/badge)](https://www.bestpractices.dev/projects/15082)
7
8
  [![codecov](https://codecov.io/gh/tufantunc/ssh-mcp/graph/badge.svg?branch=main)](https://codecov.io/gh/tufantunc/ssh-mcp)
8
9
  [![License](https://img.shields.io/github/license/tufantunc/ssh-mcp)](./LICENSE)
9
10
  [![GitHub issues](https://img.shields.io/github/issues/tufantunc/ssh-mcp)](https://github.com/tufantunc/ssh-mcp/issues)
@@ -763,7 +764,7 @@ ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
763
764
  | `--bearerToken` | required | Bearer token for authentication (all routes except `GET /health`) |
764
765
  | `--httpPort` | 3000 | HTTP listen port |
765
766
  | `--httpHost` | 127.0.0.1 | Bind address |
766
- | `--rateLimit` | 0 (off) | Max requests per minute (0 = unlimited) |
767
+ | `--rateLimit` | 0 (off) | Max authenticated requests per minute, per client (0 = unlimited) |
767
768
  | `--authFailureLimit` | 10 | Failed bearer-auth attempts allowed per client per minute (0 = off) |
768
769
  | `--trustProxy` | false | Read the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer |
769
770
  | `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
@@ -776,7 +777,13 @@ case of a config bind mount that silently did not attach: the server binds the p
776
777
  refuses every tool call. `GET /status` carries the profile list itself and stays behind the
777
778
  bearer token.
778
779
 
779
- When rate limit is exceeded, the server returns HTTP 429 with `Retry-After` header and a JSON-RPC error body so MCP clients can handle it gracefully.
780
+ `--rateLimit` is a budget per client, keyed the same way as the failed-auth budget below,
781
+ and every authenticated request spends from it: `POST /` and the rest of the MCP route,
782
+ `GET /status`, and an authenticated request to a path that does not exist. `GET /health`
783
+ never does. A wrong token never does either, so unauthenticated traffic cannot spend a
784
+ working client's budget. When a client's budget is spent the server returns HTTP 429 with
785
+ a JSON-RPC error body, and `Retry-After` gives the seconds until that client's next request
786
+ will be accepted.
780
787
 
781
788
  Failed authentication is throttled separately, and on by default. `--rateLimit` never saw a
782
789
  wrong bearer token, because the auth check answers before the limiter is reached — so
@@ -784,18 +791,20 @@ guessing ran at network speed. `--authFailureLimit` gives each client its own sm
784
791
  spent only on a 401; a correct token never consumes from it, so a working client never
785
792
  throttles itself. Once an address has spent its budget every request from it waits,
786
793
  including one with the right token — that is deliberate, since answering the guess would
787
- otherwise tell the caller which token was right. Clients are told apart by socket address. **Behind a
788
- reverse proxy that means every client shares one budget**, so set `--trustProxy` when the
789
- proxy is yours — the server prints a warning the first time it sees `X-Forwarded-For`
790
- without it. `--trustProxy` takes the *rightmost* `X-Forwarded-For` entry, which is the hop
791
- the proxy itself appended; everything to its left came from the client, so reading the
792
- leftmost would let a client choose its own budget or spend a victim's. That only holds if a
793
- proxy actually appended the entry, so the header is read **only when the peer is the
794
- proxy** — bare `--trustProxy` means a loopback peer, which is the deployment above; name a
795
- proxy elsewhere with `--trustedProxies`. When the header cannot be read as an address, or
796
- the peer is not trusted, the server falls back to the socket address and says so once, so
797
- a flag that is not taking effect is not silent. One trusted hop is assumed. A malformed `--authFailureLimit` is refused at startup rather than silently
798
- disabling the check; only `0` turns it off.
794
+ otherwise tell the caller which token was right. Clients are told apart by socket address,
795
+ for this budget and for `--rateLimit` alike. **Behind a reverse proxy that means every
796
+ client shares one of each**, so set `--trustProxy` when the proxy is yours — the server
797
+ prints a warning the first time it sees `X-Forwarded-For` without it. `--trustProxy` takes
798
+ the *rightmost* `X-Forwarded-For` entry, which is the hop the proxy itself appended;
799
+ everything to its left came from the client, so reading the leftmost would let a client
800
+ choose its own budget or spend a victim's. That only holds if a proxy actually appended
801
+ the entry, so the header is read **only when the peer is the proxy** — bare `--trustProxy`
802
+ means a loopback peer, which is the deployment above; name a proxy elsewhere with
803
+ `--trustedProxies`. When the header cannot be read as an address, or the peer is not
804
+ trusted, the server falls back to the socket address and says so once, so a flag that is
805
+ not taking effect is not silent. One trusted hop is assumed. A malformed
806
+ `--authFailureLimit` is refused at startup rather than silently disabling the check; only
807
+ `0` turns it off.
799
808
 
800
809
  **Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
801
810
 
@@ -845,7 +854,7 @@ Secrets are **never** passed as CLI arguments.
845
854
  | `--httpPort` | 3000 | HTTP transport port |
846
855
  | `--httpHost` | 127.0.0.1 | HTTP bind address |
847
856
  | `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
848
- | `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
857
+ | `--rateLimit` | 0 | Max authenticated requests per minute, per client (0 = unlimited) |
849
858
  | `--authFailureLimit` | 10 | Failed bearer-auth attempts allowed per client per minute (0 = off) |
850
859
  | `--trustProxy` | false | Read the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer |
851
860
  | `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
@@ -13,6 +13,54 @@ export const DEFAULT_AUTH_FAILURE_LIMIT = 10;
13
13
  /** Cap on tracked clients, for the reason `MAX_SESSIONS` exists: churn must not grow a map. */
14
14
  export const MAX_TRACKED_CLIENTS = 1024;
15
15
  const REFILL_INTERVAL_MS = 60_000;
16
+ /** Whole tokens earned since `lastRefill`. Negative when the clock stepped back; callers clamp. */
17
+ function tokensEarned(bucket, maxTokens) {
18
+ return Math.floor(((Date.now() - bucket.lastRefill) / REFILL_INTERVAL_MS) * maxTokens);
19
+ }
20
+ /** Tokens the bucket would hold if refilled now, without changing it. */
21
+ function availableTokens(bucket, maxTokens) {
22
+ return Math.min(maxTokens, bucket.tokens + Math.max(tokensEarned(bucket, maxTokens), 0));
23
+ }
24
+ /**
25
+ * How long until this bucket earns its next token, for `Retry-After`.
26
+ *
27
+ * Exact, where it used to be the fixed `REFILL_INTERVAL_MS / maxTokens`. That is how long
28
+ * one token takes, so a client refused halfway through the interval was told to wait
29
+ * twice as long as it had to. Bounded on both sides: below at a second, because
30
+ * `Retry-After` is whole seconds and `0` reads as "retry now"; above at one token's
31
+ * interval, because a clock that stepped backwards puts `lastRefill` in the future, and
32
+ * the raw difference would then advertise an arbitrarily long wait. Rounded up to whole
33
+ * milliseconds: a limit that does not divide a minute gives a fractional interval, and
34
+ * rounding down would name a moment at which the token has not yet arrived.
35
+ */
36
+ function nextTokenWaitMs(bucket, maxTokens) {
37
+ const interval = REFILL_INTERVAL_MS / maxTokens;
38
+ return Math.max(1000, Math.ceil(Math.min(interval, bucket.lastRefill + interval - Date.now())));
39
+ }
40
+ /**
41
+ * The tracked key whose bucket holds the most tokens *after refill*: the entry with the
42
+ * least worth remembering, so the one to evict.
43
+ *
44
+ * Refilled, not stored. A key that stops sending keeps its stored count forever, so
45
+ * ranking by `tokens` treated a bucket spent an hour ago as still spent. A table that
46
+ * was saturated once was then judged saturated for good, which was measured.
47
+ *
48
+ * Linear in the table, and left that way: it runs only when a new key arrives at a full
49
+ * table, and stops early at the first full bucket. The worst case, every bucket partly
50
+ * spent, measured about 46µs per new key at 1024 entries (Apple M4 Max, Node 24), and
51
+ * the same for both limiters.
52
+ */
53
+ function fullestBucket(buckets, maxTokens) {
54
+ let fullest;
55
+ for (const [key, bucket] of buckets) {
56
+ const available = availableTokens(bucket, maxTokens);
57
+ if (fullest === undefined || available > fullest.available)
58
+ fullest = { key, available };
59
+ if (available === maxTokens)
60
+ break;
61
+ }
62
+ return fullest;
63
+ }
16
64
  /**
17
65
  * One token-bucket step, shared by the two limiters so the refill arithmetic exists once.
18
66
  *
@@ -20,8 +68,7 @@ const REFILL_INTERVAL_MS = 60_000;
20
68
  * idle server costs nothing and there is no interval to clean up.
21
69
  */
22
70
  function consume(bucket, maxTokens) {
23
- const elapsed = Date.now() - bucket.lastRefill;
24
- const refilled = Math.floor((elapsed / REFILL_INTERVAL_MS) * maxTokens);
71
+ const refilled = tokensEarned(bucket, maxTokens);
25
72
  if (refilled > 0) {
26
73
  bucket.tokens = Math.min(maxTokens, bucket.tokens + refilled);
27
74
  bucket.lastRefill += Math.round((refilled / maxTokens) * REFILL_INTERVAL_MS);
@@ -30,17 +77,41 @@ function consume(bucket, maxTokens) {
30
77
  bucket.tokens--;
31
78
  return { allowed: true, retryAfterMs: 0 };
32
79
  }
33
- return { allowed: false, retryAfterMs: Math.ceil(REFILL_INTERVAL_MS / maxTokens) };
80
+ return { allowed: false, retryAfterMs: nextTokenWaitMs(bucket, maxTokens) };
34
81
  }
35
- class RateLimiter {
82
+ /**
83
+ * A request bucket per client, so `--rateLimit` means N requests per minute *per caller*.
84
+ *
85
+ * It was one bucket for the process: one client spent `--rateLimit` for every other, and
86
+ * a client that had sent nothing was refused (#187, measured). Keyed by `clientKey()`,
87
+ * the same key the failure budget uses, and charged only after the token check passes,
88
+ * so unauthenticated traffic cannot create entries or drain anyone's budget.
89
+ *
90
+ * Bounded like `AuthFailureLimiter`, and it evicts the fullest bucket for the same reason.
91
+ * Where it differs: an arriving key always starts full, even when every tracked bucket is
92
+ * spent. Saturating this table takes 1024 addresses that hold the token, and a token
93
+ * holder with that many addresses already has that many budgets. The refund opens nothing
94
+ * new, while starting empty would refuse a legitimate client's first request because of
95
+ * other clients' traffic.
96
+ */
97
+ export class ClientRateLimiter {
36
98
  maxTokens;
37
- bucket;
99
+ buckets = new Map();
38
100
  constructor(maxTokens) {
39
101
  this.maxTokens = maxTokens;
40
- this.bucket = { tokens: maxTokens, lastRefill: Date.now() };
41
102
  }
42
- tryConsume() {
43
- return consume(this.bucket, this.maxTokens);
103
+ tryConsume(key) {
104
+ let bucket = this.buckets.get(key);
105
+ if (bucket === undefined) {
106
+ if (this.buckets.size >= MAX_TRACKED_CLIENTS) {
107
+ const fullest = fullestBucket(this.buckets, this.maxTokens);
108
+ if (fullest !== undefined)
109
+ this.buckets.delete(fullest.key);
110
+ }
111
+ bucket = { tokens: this.maxTokens, lastRefill: Date.now() };
112
+ this.buckets.set(key, bucket);
113
+ }
114
+ return consume(bucket, this.maxTokens);
44
115
  }
45
116
  }
46
117
  /**
@@ -50,8 +121,8 @@ class RateLimiter {
50
121
  * limiter was reached, so a wrong bearer token consumed nothing and guessing ran at network
51
122
  * speed with no backoff — measured as twelve 401s and zero 429s against `--rateLimit=3`.
52
123
  * Moving the request limiter above the auth check would have closed that and opened
53
- * something worse: the request bucket is global, so unauthenticated traffic could then
54
- * starve every legitimate client.
124
+ * something worse: its 429 would answer a guess without evaluating it, and unauthenticated
125
+ * traffic reaching a request bucket could spend a victim's budget under a spoofable key.
55
126
  *
56
127
  * Only failures consume a token, so a working client never builds a budget up and is never
57
128
  * throttled by its own traffic — which is what makes this safe to have on by default. It
@@ -71,12 +142,9 @@ export class AuthFailureLimiter {
71
142
  const bucket = this.buckets.get(key);
72
143
  if (bucket === undefined)
73
144
  return { allowed: true, retryAfterMs: 0 };
74
- const elapsed = Date.now() - bucket.lastRefill;
75
- const refilled = Math.floor((elapsed / REFILL_INTERVAL_MS) * this.maxTokens);
76
- const available = Math.min(this.maxTokens, bucket.tokens + Math.max(refilled, 0));
77
- if (available > 0)
145
+ if (availableTokens(bucket, this.maxTokens) > 0)
78
146
  return { allowed: true, retryAfterMs: 0 };
79
- return { allowed: false, retryAfterMs: Math.ceil(REFILL_INTERVAL_MS / this.maxTokens) };
147
+ return { allowed: false, retryAfterMs: nextTokenWaitMs(bucket, this.maxTokens) };
80
148
  }
81
149
  /** Charge this client for a failed attempt. */
82
150
  recordFailure(key) {
@@ -89,16 +157,9 @@ export class AuthFailureLimiter {
89
157
  // first and then handed its key a fresh budget: minting enough keys cleared a
90
158
  // lockout, which was measured end to end. A full bucket is the one with nothing
91
159
  // worth remembering.
92
- let fullestKey = null;
93
- let fullest = -1;
94
- for (const [k, b] of this.buckets) {
95
- if (b.tokens > fullest) {
96
- fullest = b.tokens;
97
- fullestKey = k;
98
- }
99
- }
100
- if (fullestKey !== null)
101
- this.buckets.delete(fullestKey);
160
+ const fullest = fullestBucket(this.buckets, this.maxTokens);
161
+ if (fullest !== undefined)
162
+ this.buckets.delete(fullest.key);
102
163
  // Every tracked client is spent, so the table itself is the signal and a new key
103
164
  // does not get a full budget.
104
165
  //
@@ -109,8 +170,9 @@ export class AuthFailureLimiter {
109
170
  // `peek` already allows a key it has never seen, so the first attempt is free
110
171
  // either way and the second is refused either way. Measured both, identical.
111
172
  // Giving an arriving key a real budget is the refund this rule exists to stop.
112
- // Saturation needs 1024 addresses that have each spent a full budget.
113
- if (fullest <= 0)
173
+ // Saturation needs 1024 addresses that have each spent a full budget *recently*:
174
+ // `fullestBucket` ranks by refilled tokens, so the condition ends once they refill.
175
+ if (fullest === undefined || fullest.available <= 0)
114
176
  bucket = { tokens: 0, lastRefill: Date.now() };
115
177
  }
116
178
  this.buckets.set(key, bucket);
@@ -216,23 +278,25 @@ export async function startHttpServer(createMcpServer, opts) {
216
278
  ? new AuthFailureLimiter(authFailureLimit)
217
279
  : null;
218
280
  // Said once, when it turns out to matter. Behind a proxy with `--trustProxy` off, every
219
- // client is keyed on the proxy's socket address and so shares one budget — which means
220
- // ten failures from anyone locks out everyone. The README tells operators to terminate
221
- // TLS at a proxy, so this is the configuration it recommends, and the collapse is
222
- // invisible until a legitimate client is refused.
281
+ // client is keyed on the proxy's socket address and so shares one failure budget and one
282
+ // request budget — ten failures from anyone locks out everyone, and one busy client
283
+ // starves the rest. The README tells operators to terminate TLS at a proxy, so this is
284
+ // the configuration it recommends, and the collapse is invisible until a legitimate
285
+ // client is refused.
223
286
  let warnedSharedBudget = false;
224
287
  const warnSharedBudget = () => {
225
288
  if (warnedSharedBudget)
226
289
  return;
227
290
  warnedSharedBudget = true;
228
291
  console.error('POLICY WARNING: X-Forwarded-For is present but not being used to tell clients ' +
229
- 'apart, so every client shares one failed-auth budget and one failing client can ' +
230
- 'lock out the rest. Either --trustProxy is off, or the peer is not a trusted proxy ' +
231
- '(bare --trustProxy trusts a loopback peer; name others with --trustedProxies), or ' +
232
- 'the rightmost entry is not an address this server can read.');
292
+ 'apart, so every client is charged to one key: one failed-auth budget and one ' +
293
+ 'request budget between them, for whichever of the two is on, and one client can ' +
294
+ 'lock out or starve the rest. Either --trustProxy is off, or the peer is not a ' +
295
+ 'trusted proxy (bare --trustProxy trusts a loopback peer; name others with ' +
296
+ '--trustedProxies), or the rightmost entry is not an address this server can read.');
233
297
  };
234
298
  const rateLimiter = opts.rateLimit && opts.rateLimit > 0
235
- ? new RateLimiter(opts.rateLimit)
299
+ ? new ClientRateLimiter(opts.rateLimit)
236
300
  : null;
237
301
  // DNS rebinding: a page the user visits can make their browser POST to a
238
302
  // localhost server, and the bearer token does not help if the browser is
@@ -284,22 +348,24 @@ export async function startHttpServer(createMcpServer, opts) {
284
348
  // is configured — see the handler below for why that second bit is not a secret.
285
349
  const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
286
350
  if (!isHealthProbe) {
287
- // Checked before the token is compared, not after — so an exhausted budget answers
288
- // 429 without evaluating the guess. Gating only the 401 path instead would throttle
289
- // nothing: the comparison would still happen and a correct token would still be
290
- // served, so the status code would still tell an attacker which guess was right.
291
351
  let key = '';
292
- if (authFailureLimiter) {
352
+ if (authFailureLimiter || rateLimiter) {
293
353
  const resolved = clientKey(req, opts.trustProxy === true, opts.trustedProxies);
294
354
  key = resolved.key;
295
355
  // Warned in both directions. Without `--trustProxy` a proxied deployment shares
296
356
  // one budget; *with* it, an entry that could not be read leaves the same collapse
297
357
  // in place, and that case used to be the silent one — the operator had set the
298
358
  // flag and had no way to know it was not taking effect.
359
+ // Resolved for either limiter: with only `--rateLimit` on, skipping this charged
360
+ // every client to one key and kept the warning silent.
299
361
  if (resolved.forwardedIgnored || (opts.trustProxy !== true && req.headers['x-forwarded-for'])) {
300
362
  warnSharedBudget();
301
363
  }
302
364
  }
365
+ // Checked before the token is compared, not after — so an exhausted budget answers
366
+ // 429 without evaluating the guess. Gating only the 401 path instead would throttle
367
+ // nothing: the comparison would still happen and a correct token would still be
368
+ // served, so the status code would still tell an attacker which guess was right.
303
369
  if (authFailureLimiter) {
304
370
  const { allowed, retryAfterMs } = authFailureLimiter.peek(key);
305
371
  if (!allowed) {
@@ -341,23 +407,29 @@ export async function startHttpServer(createMcpServer, opts) {
341
407
  }));
342
408
  return;
343
409
  }
344
- }
345
- if (rateLimiter && url.pathname === '/' && (req.method === 'POST' || req.method === 'GET' || req.method === 'DELETE')) {
346
- const { allowed, retryAfterMs } = rateLimiter.tryConsume();
347
- if (!allowed) {
348
- const retryAfterSec = Math.ceil(retryAfterMs / 1000);
349
- res.writeHead(429, {
350
- 'Content-Type': 'application/json',
351
- 'Retry-After': String(retryAfterSec),
352
- });
353
- res.end(JSON.stringify({
354
- jsonrpc: '2.0',
355
- error: {
356
- code: -32604,
357
- message: `Rate limit exceeded. Retry after ${retryAfterSec}s.`,
358
- },
359
- }));
360
- return;
410
+ // Charged after the token check, never before: a request bucket is per client, but
411
+ // unauthenticated traffic reaching it could still spend a victim's budget under a
412
+ // spoofable key, and its 429 would answer a guess without evaluating it. Every
413
+ // authenticated route spends from it, `/status` and 404s included: `/status` was
414
+ // unlimited, so a token holder could poll it without bound.
415
+ if (rateLimiter) {
416
+ const { allowed, retryAfterMs } = rateLimiter.tryConsume(key);
417
+ if (!allowed) {
418
+ const retryAfterSec = Math.ceil(retryAfterMs / 1000);
419
+ res.writeHead(429, {
420
+ 'Content-Type': 'application/json',
421
+ 'Retry-After': String(retryAfterSec),
422
+ });
423
+ res.end(JSON.stringify({
424
+ jsonrpc: '2.0',
425
+ error: {
426
+ code: -32604,
427
+ message: `Rate limit exceeded. Retry after ${retryAfterSec}s.`,
428
+ },
429
+ id: null,
430
+ }));
431
+ return;
432
+ }
361
433
  }
362
434
  }
363
435
  if (req.method === 'POST' && url.pathname === '/') {
@@ -464,7 +536,7 @@ export async function startHttpServer(createMcpServer, opts) {
464
536
  console.error(`SSH MCP Server v2 (HTTP) listening on http://${host}:${port}`);
465
537
  console.error('Endpoints: POST / (MCP), GET /status, GET /health');
466
538
  if (rateLimiter) {
467
- console.error(`Rate limit: ${opts.rateLimit} req/min`);
539
+ console.error(`Rate limit: ${opts.rateLimit} req/min per client`);
468
540
  }
469
541
  if (authFailureLimiter) {
470
542
  console.error(`Auth failure limit: ${authFailureLimit}/min per client`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.14.0",
4
+ "version": "2.15.0",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "mcpName": "io.github.tufantunc/ssh-mcp",
7
7
  "type": "module",