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 +24 -15
- package/build/policy/classifier.js +132 -81
- package/build/transport/http.js +131 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
5
5
|
[](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
|
|
6
6
|
[](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
|
|
7
|
+
[](https://www.bestpractices.dev/projects/15082)
|
|
7
8
|
[](https://codecov.io/gh/tufantunc/ssh-mcp)
|
|
8
9
|
[](./LICENSE)
|
|
9
10
|
[](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
|
-
|
|
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
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
the
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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 |
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
926
|
-
|
|
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
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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')
|
|
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
|
|
1297
|
-
return
|
|
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 =
|
|
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
|
|
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 =
|
|
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,
|
package/build/transport/http.js
CHANGED
|
@@ -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
|
|
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:
|
|
80
|
+
return { allowed: false, retryAfterMs: nextTokenWaitMs(bucket, maxTokens) };
|
|
34
81
|
}
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
54
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
// ten failures from anyone locks out everyone
|
|
221
|
-
//
|
|
222
|
-
// invisible until a legitimate
|
|
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
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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.
|
|
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",
|