ssh-mcp 2.13.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 |
@@ -41,7 +41,7 @@ const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
41
41
  * branch, which only runs when the command words matched a READERS key
42
42
  * exactly — never sees one either.
43
43
  */
44
- function firstUnquotedGlobWord(command, honorQuotes = true) {
44
+ function firstUnquotedGlobWord(command, honorQuotes = true, dialect = 'posix') {
45
45
  let quote = null;
46
46
  let escaped = false;
47
47
  let word = '';
@@ -56,7 +56,7 @@ function firstUnquotedGlobWord(command, honorQuotes = true) {
56
56
  if (quote) {
57
57
  // Backslash is literal inside single quotes; inside double quotes it
58
58
  // escapes — the same distinction the tokeniser makes.
59
- if (ch === '\\' && quote === '"') {
59
+ if (dialect === 'posix' && ch === '\\' && quote === '"') {
60
60
  escaped = true;
61
61
  continue;
62
62
  }
@@ -66,7 +66,7 @@ function firstUnquotedGlobWord(command, honorQuotes = true) {
66
66
  word += ch;
67
67
  continue;
68
68
  }
69
- if (ch === '\\') {
69
+ if (dialect === 'posix' && ch === '\\') {
70
70
  escaped = true;
71
71
  continue;
72
72
  }
@@ -90,7 +90,7 @@ function firstUnquotedGlobWord(command, honorQuotes = true) {
90
90
  // `tokenizeSegmentsDetailed`). Judge it the same way here: in that reading
91
91
  // the glob character is unquoted.
92
92
  if (honorQuotes && quote !== null)
93
- return firstUnquotedGlobWord(command, false);
93
+ return firstUnquotedGlobWord(command, false, dialect);
94
94
  return globInWord ? word : null;
95
95
  }
96
96
  /**
@@ -822,9 +822,11 @@ const MAX_NESTING_DEPTH = 8;
822
822
  * Not a shell parser. Variable expansion, arithmetic and here-documents are out of scope —
823
823
  * `hasUnnameableCommand` is what refuses a command word this cannot resolve.
824
824
  */
825
- function tokenizeSegments(command) {
826
- return tokenizeSegmentsDetailed(command).map((segment) => segment.words);
825
+ function tokenizeSegments(command, dialect = 'posix') {
826
+ return tokenizeSegmentsDetailed(command, true, dialect).map((segment) => segment.words);
827
827
  }
828
+ /** Inside double quotes, the characters POSIX lets a backslash escape. */
829
+ const DOUBLE_QUOTE_ESCAPABLE = new Set(['$', '`', '"', '\\', '\n']);
828
830
  /**
829
831
  * The same split, keeping the separator that introduced each segment.
830
832
  *
@@ -832,7 +834,7 @@ function tokenizeSegments(command) {
832
834
  * and `&` merely sequence, and a rule about pipes must not fire on a rule about sequences.
833
835
  * Deriving both views from one tokeniser is what stops the two from drifting apart.
834
836
  */
835
- function tokenizeSegmentsDetailed(command, honorQuotes = true) {
837
+ function tokenizeSegmentsDetailed(command, honorQuotes = true, dialect = 'posix') {
836
838
  const segments = [];
837
839
  let pending = '';
838
840
  let words = [];
@@ -867,7 +869,8 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
867
869
  words = [];
868
870
  pending = sep;
869
871
  };
870
- for (const ch of command) {
872
+ for (let i = 0; i < command.length; i++) {
873
+ const ch = command[i];
871
874
  if (escaped) {
872
875
  // A backslash quotes the next character, so `\reboot` runs reboot. Keeping the
873
876
  // character and dropping the backslash is what the shell does.
@@ -877,8 +880,18 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
877
880
  }
878
881
  if (quote) {
879
882
  // Backslash is literal inside single quotes; inside double quotes it escapes.
880
- if (ch === '\\' && quote === '"') {
881
- escaped = true;
883
+ if (dialect === 'posix' && ch === '\\' && quote === '"') {
884
+ const next = command[i + 1];
885
+ // POSIX keeps `\x` before an ordinary character: only $ ` " \ and a
886
+ // newline are escapable, so the backslash survives into the word
887
+ // (GHSA-972x-g47g-3922's second finding — dropping it misread both
888
+ // dialects at once). At end of string there is no next character to
889
+ // protect, and the open quote sends the whole scan to the fallback.
890
+ if (next === undefined || DOUBLE_QUOTE_ESCAPABLE.has(next)) {
891
+ escaped = true;
892
+ continue;
893
+ }
894
+ current += ch;
882
895
  continue;
883
896
  }
884
897
  if (ch === quote)
@@ -887,7 +900,7 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
887
900
  current += ch;
888
901
  continue;
889
902
  }
890
- if (ch === '\\') {
903
+ if (dialect === 'posix' && ch === '\\') {
891
904
  escaped = true;
892
905
  continue;
893
906
  }
@@ -913,7 +926,7 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
913
926
  // `privileged` before this tokeniser and `safe` after. So fall back to the scan that
914
927
  // treats the quote character as ordinary text, which splits and still sees `sudo id`.
915
928
  if (quote !== null)
916
- return tokenizeSegmentsDetailed(command, false);
929
+ return tokenizeSegmentsDetailed(command, false, dialect);
917
930
  return segments;
918
931
  }
919
932
  /**
@@ -922,20 +935,26 @@ function tokenizeSegmentsDetailed(command, honorQuotes = true) {
922
935
  * Segments are rejoined with `; ` so a pattern cannot match across two commands that the
923
936
  * shell would run separately.
924
937
  */
925
- let lastNormalizedInput = null;
926
- let lastNormalizedOutput = '';
927
- function normalizeCommand(command) {
938
+ const normalizedCache = new Map();
939
+ function normalizeCommand(command, dialect) {
928
940
  // One evaluate asks for this about nineteen times, once per regex rule, and each ask
929
941
  // re-ran the tokeniser over the whole command. The asks arrive in a row on the same
930
- // input, so a one-entry cache removes almost all of it.
931
- if (command === lastNormalizedInput)
932
- return lastNormalizedOutput;
933
- lastNormalizedInput = command;
934
- lastNormalizedOutput = normalizeUncached(command);
935
- return lastNormalizedOutput;
942
+ // input, so a small cache, evicting the oldest inserted entry, removes almost
943
+ // all of it. The key carries the dialect because both readings of one command
944
+ // are asked about in the same row.
945
+ const key = `${dialect}\u0000${command}`;
946
+ const hit = normalizedCache.get(key);
947
+ if (hit !== undefined)
948
+ return hit;
949
+ const value = normalizeUncached(command, dialect);
950
+ normalizedCache.set(key, value);
951
+ if (normalizedCache.size > 8) {
952
+ normalizedCache.delete(normalizedCache.keys().next().value);
953
+ }
954
+ return value;
936
955
  }
937
- function normalizeUncached(command) {
938
- return tokenizeSegments(command)
956
+ function normalizeUncached(command, dialect) {
957
+ return tokenizeSegments(command, dialect)
939
958
  .map((words) => words.map((w) => (QUOTED_CONTENT.test(w) ? PLACEHOLDER : w)).join(' '))
940
959
  .join('; ');
941
960
  }
@@ -976,8 +995,8 @@ const PLACEHOLDER = '\u0000';
976
995
  * inside a quoted argument into a real one — `grep -E "warn|reboot" syslog` became an
977
996
  * unconditional refusal, which is the mention-vs-invocation bug of #91 all over again.
978
997
  */
979
- function matchesEitherForm(command, test) {
980
- return test(command) || test(normalizeCommand(command));
998
+ function matchesEitherForm(command, test, dialect) {
999
+ return test(command) || test(normalizeCommand(command, dialect));
981
1000
  }
982
1001
  /**
983
1002
  * The commands hiding inside a command.
@@ -1008,7 +1027,7 @@ function matchesEitherForm(command, test) {
1008
1027
  * backticks and `sh -c` are certain carriers and keep reaching the
1009
1028
  * denylist either way.
1010
1029
  */
1011
- export function nestedCommands(command, speculativeOperands = true) {
1030
+ export function nestedCommands(command, speculativeOperands = true, dialect = 'posix') {
1012
1031
  const found = [];
1013
1032
  // `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
1014
1033
  // cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
@@ -1046,7 +1065,7 @@ export function nestedCommands(command, speculativeOperands = true) {
1046
1065
  // scan above does not see it. Read each segment's command word rather than scanning
1047
1066
  // every word: matching a mention rather than an invocation is #91, and
1048
1067
  // `cat /usr/bin/python3` names an interpreter without running one.
1049
- const segments = tokenizeSegmentsDetailed(command);
1068
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
1050
1069
  for (const { words } of segments) {
1051
1070
  // Every word, not just the command word. Stopping at the head lost the carrier behind
1052
1071
  // any wrapper this file does not list: `xargs -I {} sh -c 'sudo id'` stops on `{}`, and
@@ -1277,9 +1296,16 @@ const BARE_NUMERIC = /^\d+(\.\d+)?[smhd]?$/;
1277
1296
  * `\sudo`, `'sudo'` and `"sudo"` all execute sudo — the backslash only
1278
1297
  * suppresses alias expansion — but a verbatim string comparison sees three
1279
1298
  * different words. Without this, a one-character edit walks around the check.
1299
+ *
1300
+ * One surrounding quote pair, and nothing else: the scanner has already
1301
+ * resolved escapes by the time a word reaches this, in both the
1302
+ * quote-honouring scan and the `honorQuotes = false` fallback, so a second
1303
+ * pass here could only remove backslashes the dialect meant to keep — the
1304
+ * literal ones inside single quotes, and the POSIX-retained ones inside
1305
+ * double quotes (GHSA-972x-g47g-3922).
1280
1306
  */
1281
1307
  function unquote(word) {
1282
- return word.replace(/^(['"])(.*)\1$/, '$2').replace(/\\(.)/g, '$1');
1308
+ return word.replace(/^(['"])(.*)\1$/, '$2');
1283
1309
  }
1284
1310
  /**
1285
1311
  * Privilege-prefix flags that consume the next argument, so `sudo -u root
@@ -1291,14 +1317,14 @@ const PREFIX_VALUE_FLAGS = new Set([
1291
1317
  '--user', '--group', '--prompt', '--close-from', '--host', '--role', '--type',
1292
1318
  '--other-user', '--command',
1293
1319
  ]);
1294
- /** `/sbin/reboot` and `reboot` are the same invocation. */
1320
+ /** `/sbin/reboot`, `C:\Windows\reboot.exe` and `reboot` are the same invocation. */
1295
1321
  function stripPath(word) {
1296
- const slash = word.lastIndexOf('/');
1297
- return slash === -1 ? word : word.slice(slash + 1);
1322
+ const cut = Math.max(word.lastIndexOf('/'), word.lastIndexOf('\\'));
1323
+ return cut === -1 ? word : word.slice(cut + 1);
1298
1324
  }
1299
- function parseSegments(command) {
1325
+ function parseSegments(command, dialect = 'posix') {
1300
1326
  const segments = [];
1301
- for (const words of tokenizeSegments(command)) {
1327
+ for (const words of tokenizeSegments(command, dialect)) {
1302
1328
  const segment = parseWords(words);
1303
1329
  if (segment !== null)
1304
1330
  segments.push(segment);
@@ -1383,8 +1409,8 @@ function elevatedBinary(words) {
1383
1409
  * holding, and `echo hi; sudo id` recorded `binary: "echo"` against a
1384
1410
  * privileged decision — in the audit log, the OTel span and OPA's input (#134).
1385
1411
  */
1386
- function elevatedBinaryOf(command) {
1387
- for (const words of tokenizeSegments(command)) {
1412
+ function elevatedBinaryOf(command, dialect = 'posix') {
1413
+ for (const words of tokenizeSegments(command, dialect)) {
1388
1414
  const found = elevatedBinary(words);
1389
1415
  if (found !== null)
1390
1416
  return found;
@@ -1411,8 +1437,8 @@ function elevatedBinaryOf(command) {
1411
1437
  * `invokedWords` also reads it, and widening what counts as "the command"
1412
1438
  * there is a different, larger change this fix does not take on.
1413
1439
  */
1414
- function hasDisqualifyingArgs(command) {
1415
- return tokenizeSegments(command).some((words) => {
1440
+ function hasDisqualifyingArgs(command, dialect = 'posix') {
1441
+ return tokenizeSegments(command, dialect).some((words) => {
1416
1442
  const idx = effectiveCommandIndex(words);
1417
1443
  if (idx === -1)
1418
1444
  return false;
@@ -1420,9 +1446,9 @@ function hasDisqualifyingArgs(command) {
1420
1446
  return rule !== undefined && words.slice(idx + 1).some((arg) => rule.test(arg));
1421
1447
  });
1422
1448
  }
1423
- function invokedWords(command) {
1449
+ function invokedWords(command, dialect = 'posix') {
1424
1450
  const invoked = [];
1425
- for (const { head, args } of parseSegments(command)) {
1451
+ for (const { head, args } of parseSegments(command, dialect)) {
1426
1452
  invoked.push(head);
1427
1453
  // `systemctl reboot` restarts the host. Reading a unit that happens to be
1428
1454
  // named after a power action is rare enough that erring towards refusal
@@ -1444,11 +1470,11 @@ const DOWNLOADERS = new Set(['curl', 'wget']);
1444
1470
  * runs a shell when the download fails, which is not meaningfully safer than
1445
1471
  * running one when it succeeds.
1446
1472
  */
1447
- function pipesDownloadIntoShell(command) {
1473
+ function pipesDownloadIntoShell(command, dialect = 'posix') {
1448
1474
  // One head per pipe stage. Splitting on every separator would make `curl -O x;
1449
1475
  // bash build.sh` — download, then run a local script — match a rule whose label says
1450
1476
  // "piping a download into a shell", on a list that cannot be switched off.
1451
- const segments = tokenizeSegmentsDetailed(command);
1477
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
1452
1478
  const heads = segments
1453
1479
  .filter((segment, i) => i === 0 || segment.sep === '|')
1454
1480
  .map((segment) => parseWords(segment.words)?.head);
@@ -1458,8 +1484,8 @@ function pipesDownloadIntoShell(command) {
1458
1484
  return heads.slice(firstDownload + 1).some((h) => h !== undefined && SHELLS.has(h));
1459
1485
  }
1460
1486
  /** `dd … of=/dev/sda` — writing an image straight onto a block device. */
1461
- function writesToDevice(command) {
1462
- return parseSegments(command).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
1487
+ function writesToDevice(command, dialect = 'posix') {
1488
+ return parseSegments(command, dialect).some(({ head, args }) => head === 'dd' && args.some((a) => a.startsWith('of=/dev/')));
1463
1489
  }
1464
1490
  /**
1465
1491
  * `chown -R … /` — a recursive chown whose target is the filesystem root.
@@ -1467,8 +1493,8 @@ function writesToDevice(command) {
1467
1493
  * The last non-flag argument is the target; `chown -R app:app /srv/app` is
1468
1494
  * ordinary and stays allowed.
1469
1495
  */
1470
- function chownsRoot(command) {
1471
- return parseSegments(command).some(({ head, args }) => {
1496
+ function chownsRoot(command, dialect = 'posix') {
1497
+ return parseSegments(command, dialect).some(({ head, args }) => {
1472
1498
  if (head !== 'chown' || !args.includes('-R'))
1473
1499
  return false;
1474
1500
  const positional = args.filter((a) => !a.startsWith('-'));
@@ -1478,18 +1504,18 @@ function chownsRoot(command) {
1478
1504
  const FORBIDDEN_RULES = [
1479
1505
  ...FORBIDDEN_PATTERNS.map((re) => ({
1480
1506
  label: String(re),
1481
- test: (c) => matchesEitherForm(c, (form) => re.test(form)),
1507
+ test: (c, dialect) => matchesEitherForm(c, (form) => re.test(form), dialect),
1482
1508
  })),
1483
1509
  {
1484
1510
  label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
1485
- test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
1511
+ test: (command, dialect) => invokedWords(command, dialect).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
1486
1512
  },
1487
1513
  {
1488
1514
  label: 'piping a download into a shell (curl or wget into sh, bash or zsh)',
1489
- test: pipesDownloadIntoShell,
1515
+ test: (c, dialect) => pipesDownloadIntoShell(c, dialect),
1490
1516
  },
1491
- { label: 'dd writing to a block device (of=/dev/…)', test: writesToDevice },
1492
- { label: 'a recursive chown of the filesystem root', test: chownsRoot },
1517
+ { label: 'dd writing to a block device (of=/dev/…)', test: (c, dialect) => writesToDevice(c, dialect) },
1518
+ { label: 'a recursive chown of the filesystem root', test: (c, dialect) => chownsRoot(c, dialect) },
1493
1519
  ];
1494
1520
  /**
1495
1521
  * Which never-allowed rule this command trips, or null. The single entry point:
@@ -1498,8 +1524,21 @@ const FORBIDDEN_RULES = [
1498
1524
  * permit `sudo reboot`.
1499
1525
  */
1500
1526
  export function findForbiddenMatch(command, depth = 0) {
1527
+ // The engine calls this directly, so it is a decision point of its own and
1528
+ // reads both dialects like `classifyCommand` does: a refusal under either
1529
+ // reading is a refusal. The destructive-class consultation *inside* a
1530
+ // dialect pass (`isDestructive`) stays in-dialect — that pass is already one
1531
+ // reading of the whole string.
1532
+ if (command.includes('\\')) {
1533
+ const windows = findForbiddenMatchInDialect(command, depth, 'windows');
1534
+ if (windows !== null)
1535
+ return windows;
1536
+ }
1537
+ return findForbiddenMatchInDialect(command, depth, 'posix');
1538
+ }
1539
+ function findForbiddenMatchInDialect(command, depth, dialect) {
1501
1540
  for (const rule of FORBIDDEN_RULES) {
1502
- if (rule.test(command))
1541
+ if (rule.test(command, dialect))
1503
1542
  return rule.label;
1504
1543
  }
1505
1544
  // The same carriers the class scan reads, for the same reason. This list is the
@@ -1523,15 +1562,15 @@ export function findForbiddenMatch(command, depth = 0) {
1523
1562
  // `nestedCommands` regardless of this flag.
1524
1563
  if (depth >= MAX_NESTING_DEPTH)
1525
1564
  return null;
1526
- for (const inner of nestedCommands(command, false)) {
1527
- const match = findForbiddenMatch(inner, depth + 1);
1565
+ for (const inner of nestedCommands(command, false, dialect)) {
1566
+ const match = findForbiddenMatchInDialect(inner, depth + 1, dialect);
1528
1567
  if (match !== null)
1529
1568
  return match;
1530
1569
  }
1531
1570
  return null;
1532
1571
  }
1533
- export function isForbidden(command) {
1534
- return findForbiddenMatch(command) !== null;
1572
+ export function isForbidden(command, dialect = 'posix') {
1573
+ return findForbiddenMatchInDialect(command, 0, dialect) !== null;
1535
1574
  }
1536
1575
  /**
1537
1576
  * Commands that are destructive but legitimate under approval — e.g.
@@ -1548,9 +1587,9 @@ const DESTRUCTIVE_PATTERNS = [
1548
1587
  * reading a log that mentions `reboot` is no longer classified destructive
1549
1588
  * either.
1550
1589
  */
1551
- function isDestructive(command) {
1552
- return (isForbidden(command) ||
1553
- matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form))));
1590
+ function isDestructive(command, dialect = 'posix') {
1591
+ return (isForbidden(command, dialect) ||
1592
+ matchesEitherForm(command, (form) => DESTRUCTIVE_PATTERNS.some((re) => re.test(form)), dialect));
1554
1593
  }
1555
1594
  const LEADING_PRIVILEGE_PREFIXES = [
1556
1595
  /^\s*sudo\b/,
@@ -1558,13 +1597,13 @@ const LEADING_PRIVILEGE_PREFIXES = [
1558
1597
  /^\s*doas\b/,
1559
1598
  /^\s*pkexec\b/,
1560
1599
  ];
1561
- export function extractBinary(command) {
1600
+ export function extractBinary(command, dialect = 'posix') {
1562
1601
  // The first segment's words, so quoting is resolved — `s"u"do id` used to report
1563
1602
  // `s"u"do` in the audit record and the refusal message. Reading the normalised whole
1564
1603
  // command instead would be wrong in the other direction: it rejoins segments with
1565
1604
  // `; `, so `ls | grep x` would report `ls;`, and this name reaches the audit record,
1566
1605
  // the OTel span and OPA's input (#134). A separator is not a binary.
1567
- let cmd = (tokenizeSegments(command)[0] ?? []).join(' ').trim();
1606
+ let cmd = (tokenizeSegments(command, dialect)[0] ?? []).join(' ').trim();
1568
1607
  for (const prefix of LEADING_PRIVILEGE_PREFIXES) {
1569
1608
  cmd = cmd.replace(prefix, '').trim();
1570
1609
  }
@@ -1910,8 +1949,8 @@ function awkFindings(words) {
1910
1949
  * `awk 'BEGIN{system("sudo id")}'` comes out `privileged` rather than flattened
1911
1950
  * to the `destructive` this function reports.
1912
1951
  */
1913
- function hasDangerousAwk(command) {
1914
- for (const { words } of tokenizeSegmentsDetailed(command)) {
1952
+ function hasDangerousAwk(command, dialect = 'posix') {
1953
+ for (const { words } of tokenizeSegmentsDetailed(command, true, dialect)) {
1915
1954
  const findings = awkFindings(words);
1916
1955
  if (findings === null)
1917
1956
  continue;
@@ -1923,15 +1962,15 @@ function hasDangerousAwk(command) {
1923
1962
  // prints into it, and that is assembled at run time. So it is the same
1924
1963
  // "we cannot tell" this module reports for `-f progfile`.
1925
1964
  for (const target of findings.pipedInto) {
1926
- const stage = tokenizeSegmentsDetailed(target)[0];
1965
+ const stage = tokenizeSegmentsDetailed(target, true, dialect)[0];
1927
1966
  if (stage !== undefined && readsProgramFromStdin(stage.words))
1928
1967
  return true;
1929
1968
  }
1930
1969
  }
1931
1970
  return false;
1932
1971
  }
1933
- function hasUnreadableProgram(command) {
1934
- const segments = tokenizeSegmentsDetailed(command);
1972
+ function hasUnreadableProgram(command, dialect = 'posix') {
1973
+ const segments = tokenizeSegmentsDetailed(command, true, dialect);
1935
1974
  for (let i = 0; i < segments.length; i++) {
1936
1975
  const { words, sep } = segments[i];
1937
1976
  if (sep === '|' && readsProgramFromStdin(words))
@@ -1971,8 +2010,8 @@ function hasUnreadableProgram(command) {
1971
2010
  * Only the command word, never the arguments. `echo $HOME` names a command we know;
1972
2011
  * promoting that would put a prompt on most ordinary shell usage.
1973
2012
  */
1974
- function hasUnnameableCommand(command) {
1975
- for (const words of tokenizeSegments(command)) {
2013
+ function hasUnnameableCommand(command, dialect = 'posix') {
2014
+ for (const words of tokenizeSegments(command, dialect)) {
1976
2015
  const head = effectiveCommandWord(words);
1977
2016
  if (head !== null && /[$`]/.test(head))
1978
2017
  return true;
@@ -2018,8 +2057,8 @@ const SYNTHETIC_CLASSES = Object.assign(Object.create(null), {
2018
2057
  'session:open': 'destructive',
2019
2058
  });
2020
2059
  /** The first word, tokenised — the synthetic verb when there is one. */
2021
- function syntheticVerb(command) {
2022
- return tokenizeSegments(command)[0]?.[0] ?? '';
2060
+ function syntheticVerb(command, dialect = 'posix') {
2061
+ return tokenizeSegments(command, dialect)[0]?.[0] ?? '';
2023
2062
  }
2024
2063
  /**
2025
2064
  * The class of a command, and of everything it carries.
@@ -2033,8 +2072,20 @@ function syntheticVerb(command) {
2033
2072
  * Taking the maximum is what makes the scan unable to lower anything.
2034
2073
  */
2035
2074
  export function classifyCommand(command, depth = 0) {
2075
+ // One string, two dialects, and the class is the worse of the two readings:
2076
+ // the classifier cannot know whether the host shell reads a backslash as an
2077
+ // escape or as a path separator, so policy must hold on whichever host
2078
+ // receives the command (GHSA-972x-g47g-3922). A command without a backslash
2079
+ // is byte-identical under both dialects, so it keeps the single pass.
2080
+ if (!command.includes('\\'))
2081
+ return classifyCommandInDialect(command, depth, 'posix');
2082
+ const posix = classifyCommandInDialect(command, depth, 'posix');
2083
+ const windows = classifyCommandInDialect(command, depth, 'windows');
2084
+ return CLASS_RANK[windows.class] > CLASS_RANK[posix.class] ? windows : posix;
2085
+ }
2086
+ function classifyCommandInDialect(command, depth, dialect) {
2036
2087
  const trimmed = command.trim();
2037
- const outer = classifyOuter(trimmed);
2088
+ const outer = classifyOuter(trimmed, dialect);
2038
2089
  if (depth >= MAX_NESTING_DEPTH) {
2039
2090
  // Nesting this deep is not something an operator writes, and we have stopped
2040
2091
  // reading. Refusing to guess is the only answer consistent with the rest of this
@@ -2045,13 +2096,13 @@ export function classifyCommand(command, depth = 0) {
2045
2096
  // A floor, not a verdict. Returning the synthetic class outright would put it above
2046
2097
  // the elevation and never-allowed checks, so `sftp:upload /tmp/x; sudo id` would
2047
2098
  // record `destructive` where the command is `privileged`.
2048
- const verb = syntheticVerb(trimmed);
2099
+ const verb = syntheticVerb(trimmed, dialect);
2049
2100
  const floor = SYNTHETIC_CLASSES[verb];
2050
2101
  if (floor !== undefined && CLASS_RANK[floor] > CLASS_RANK[highest.class]) {
2051
2102
  highest = { binary: verb, fullCommand: trimmed, class: floor };
2052
2103
  }
2053
- for (const inner of nestedCommands(trimmed)) {
2054
- const parsed = classifyCommand(inner, depth + 1);
2104
+ for (const inner of nestedCommands(trimmed, true, dialect)) {
2105
+ const parsed = classifyCommandInDialect(inner, depth + 1, dialect);
2055
2106
  // `binary` follows the winning side deliberately: it is what the audit record and
2056
2107
  // the refusal message name, and naming the outer `echo` would describe the wrong
2057
2108
  // process as the one that ran as root.
@@ -2073,18 +2124,18 @@ export function classifyCommand(command, depth = 0) {
2073
2124
  return result;
2074
2125
  }
2075
2126
  /** The class of the command itself, reading none of what it carries. */
2076
- function classifyOuter(trimmed) {
2077
- const binary = extractBinary(trimmed);
2127
+ function classifyOuter(trimmed, dialect) {
2128
+ const binary = extractBinary(trimmed, dialect);
2078
2129
  const fullCommand = trimmed;
2079
2130
  // `binary` names the subject of the class. For everything below it is the
2080
2131
  // leading command; here it is the one that runs as root, which are the same
2081
2132
  // thing only when the prefix leads.
2082
- const elevated = elevatedBinaryOf(trimmed);
2133
+ const elevated = elevatedBinaryOf(trimmed, dialect);
2083
2134
  if (elevated !== null) {
2084
2135
  return { binary: elevated, fullCommand, class: 'privileged' };
2085
2136
  }
2086
- if (hasUnreadableProgram(trimmed) || hasDangerousAwk(trimmed)
2087
- || isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
2137
+ if (hasUnreadableProgram(trimmed, dialect) || hasDangerousAwk(trimmed, dialect)
2138
+ || isDestructive(trimmed, dialect) || hasDisqualifyingArgs(trimmed, dialect)) {
2088
2139
  return { binary, fullCommand, class: 'destructive' };
2089
2140
  }
2090
2141
  // Below this point every branch assumes the command word names something we
@@ -2095,10 +2146,10 @@ function classifyOuter(trimmed) {
2095
2146
  // `destructive` rather than `privileged`: this is "we cannot tell", not "this is
2096
2147
  // root". It gates on approval instead of refusing outright, which keeps
2097
2148
  // `$PREFIX/bin/tool` usable for a role that holds `destructive` on the tier.
2098
- if (hasUnnameableCommand(trimmed)) {
2149
+ if (hasUnnameableCommand(trimmed, dialect)) {
2099
2150
  return { binary, fullCommand, class: 'destructive' };
2100
2151
  }
2101
- const words = tokenizeSegments(fullCommand)[0] ?? [];
2152
+ const words = tokenizeSegments(fullCommand, dialect)[0] ?? [];
2102
2153
  const twoWordPrefix = words.slice(0, 2).join(' ');
2103
2154
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
2104
2155
  || READ_ONLY_SYNTHETIC.has(binary)) {
@@ -2125,7 +2176,7 @@ function classifyOuter(trimmed) {
2125
2176
  // no option or operand of the binary writes or executes, so extra
2126
2177
  // operands from an expansion are still data.
2127
2178
  if (entry.grammar.args !== 'any') {
2128
- const globWord = firstUnquotedGlobWord(fullCommand);
2179
+ const globWord = firstUnquotedGlobWord(fullCommand, true, dialect);
2129
2180
  if (globWord !== null) {
2130
2181
  return {
2131
2182
  binary,
@@ -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.13.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",