ssh-mcp 2.14.0 → 2.16.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 +32 -15
- package/build/transport/http.js +204 -67
- 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,28 @@ 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.
|
|
808
|
+
|
|
809
|
+
An IPv6 client is told apart by its /64 rather than its full address, for both budgets. A
|
|
810
|
+
/64 is usually the smallest block a subscriber is handed, so hosts that share one — a home
|
|
811
|
+
or office network, or customers a hosting provider places on one /64 — share a budget, the
|
|
812
|
+
way hosts behind one IPv4 NAT do. An IPv6 address that carries an IPv4 address (IPv4-mapped,
|
|
813
|
+
or the NAT64 prefix `64:ff9b::/96`) counts as that IPv4 client. A NAT64 or SIIT translator
|
|
814
|
+
that uses a prefix of its own is not recognised: every IPv4 client it translates shares that
|
|
815
|
+
prefix's /64, and so one budget.
|
|
799
816
|
|
|
800
817
|
**Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
|
|
801
818
|
|
|
@@ -845,7 +862,7 @@ Secrets are **never** passed as CLI arguments.
|
|
|
845
862
|
| `--httpPort` | 3000 | HTTP transport port |
|
|
846
863
|
| `--httpHost` | 127.0.0.1 | HTTP bind address |
|
|
847
864
|
| `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
|
|
848
|
-
| `--rateLimit` | 0 |
|
|
865
|
+
| `--rateLimit` | 0 | Max authenticated requests per minute, per client (0 = unlimited) |
|
|
849
866
|
| `--authFailureLimit` | 10 | Failed bearer-auth attempts allowed per client per minute (0 = off) |
|
|
850
867
|
| `--trustProxy` | false | Read the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer |
|
|
851
868
|
| `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
|
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);
|
|
@@ -119,7 +181,17 @@ export class AuthFailureLimiter {
|
|
|
119
181
|
}
|
|
120
182
|
}
|
|
121
183
|
/**
|
|
122
|
-
* Which client an attempt is charged to
|
|
184
|
+
* Which client an attempt is charged to: the address `chargedAddress` settles on, grouped
|
|
185
|
+
* into a key by `keyOf`. Grouped here, once, so no path through the header logic can hand
|
|
186
|
+
* back an ungrouped address — and trust is decided in there, on the exact peer, before any
|
|
187
|
+
* grouping happens.
|
|
188
|
+
*/
|
|
189
|
+
export function clientKey(req, trustProxy, trustedProxies) {
|
|
190
|
+
const { address, forwardedIgnored } = chargedAddress(req, trustProxy, trustedProxies);
|
|
191
|
+
return { key: keyOf(address), forwardedIgnored };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The exact address an attempt is charged to.
|
|
123
195
|
*
|
|
124
196
|
* The socket's remote address, which is the real client on a direct connection — how this
|
|
125
197
|
* server is normally run. `X-Forwarded-For` is read only when a proxy is explicitly
|
|
@@ -137,26 +209,73 @@ export class AuthFailureLimiter {
|
|
|
137
209
|
* chain of two would need the second-from-right, and this does not try to guess the depth.
|
|
138
210
|
* Anything that is not an IP address is discarded rather than used as a map key.
|
|
139
211
|
*/
|
|
140
|
-
|
|
212
|
+
function chargedAddress(req, trustProxy, trustedProxies) {
|
|
141
213
|
const peer = canonicalAddress(req.socket.remoteAddress ?? 'unknown');
|
|
142
214
|
const forwarded = req.headers['x-forwarded-for'];
|
|
143
215
|
if (!trustProxy || forwarded === undefined)
|
|
144
|
-
return {
|
|
216
|
+
return { address: peer, forwardedIgnored: false };
|
|
145
217
|
// The rightmost entry is proxy-authored only if a proxy actually appended one. Nothing
|
|
146
218
|
// about the header says whether it did, so the *peer* has to be the proxy — otherwise a
|
|
147
219
|
// client reaching the listener directly sends one forged entry, that entry is the
|
|
148
220
|
// rightmost, and it picks its own key. Both attacks this keying was fixed to stop came
|
|
149
221
|
// back alive in exactly that configuration.
|
|
150
222
|
if (!isTrustedPeer(peer, trustedProxies)) {
|
|
151
|
-
return {
|
|
223
|
+
return { address: peer, forwardedIgnored: true };
|
|
152
224
|
}
|
|
153
225
|
const raw = Array.isArray(forwarded) ? forwarded.join(',') : forwarded;
|
|
154
226
|
const entries = raw.split(',').map((e) => e.trim()).filter(Boolean);
|
|
155
227
|
const nearest = entries[entries.length - 1];
|
|
156
228
|
const address = nearest === undefined ? undefined : forwardedAddress(nearest);
|
|
157
229
|
if (address === undefined)
|
|
158
|
-
return {
|
|
159
|
-
return {
|
|
230
|
+
return { address: peer, forwardedIgnored: true };
|
|
231
|
+
return { address, forwardedIgnored: false };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The key an address is charged to: an IPv4 address as itself, an IPv6 address by its /64.
|
|
235
|
+
*
|
|
236
|
+
* A subscriber is usually handed at least a /64, so the /64 is the client. Where a provider
|
|
237
|
+
* puts several customers on one /64 they share a budget, as hosts behind one IPv4 NAT do.
|
|
238
|
+
*
|
|
239
|
+
* Three IPv6 ranges are not a client's own /64 but a way of carrying an IPv4 address, and
|
|
240
|
+
* are keyed by that address: IPv4-mapped `::ffff:0:0/96` in any spelling, the NAT64
|
|
241
|
+
* well-known prefix `64:ff9b::/96` under which a translated deployment sees every IPv4
|
|
242
|
+
* client, and IPv4-compatible `::/96`. Grouped by /64 instead, each of them would put every
|
|
243
|
+
* IPv4 client on one key. A translator using a prefix of its own is not recognised, and its
|
|
244
|
+
* IPv4 clients share that prefix's /64. Loopback stays `::1`, however it is written.
|
|
245
|
+
*/
|
|
246
|
+
function keyOf(address) {
|
|
247
|
+
if (isIP(address) !== 6)
|
|
248
|
+
return address;
|
|
249
|
+
const g = ipv6Groups(address);
|
|
250
|
+
if (g.slice(0, 7).every((n) => n === 0) && g[7] === 1)
|
|
251
|
+
return '::1';
|
|
252
|
+
const lowZero = g[2] === 0 && g[3] === 0 && g[4] === 0;
|
|
253
|
+
const carriesIpv4 = lowZero && ((g[0] === 0 && g[1] === 0 && (g[5] === 0 || g[5] === 0xffff)) ||
|
|
254
|
+
(g[0] === 0x64 && g[1] === 0xff9b && g[5] === 0));
|
|
255
|
+
if (carriesIpv4)
|
|
256
|
+
return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
|
|
257
|
+
return `${g.slice(0, 4).map((n) => n.toString(16)).join(':')}::/64`;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* An IPv6 address as its eight 16-bit groups, whatever the spelling: zone dropped, `::`
|
|
261
|
+
* expanded, a dotted IPv4 tail read as the last two groups, case and leading zeros gone by
|
|
262
|
+
* reading each group as a number. Expects an address `net.isIP` has already accepted.
|
|
263
|
+
*/
|
|
264
|
+
function ipv6Groups(address) {
|
|
265
|
+
const [head, tail] = address.split('%')[0].split('::');
|
|
266
|
+
const parse = (part) => part === undefined || part === ''
|
|
267
|
+
? []
|
|
268
|
+
: part.split(':').flatMap((group) => {
|
|
269
|
+
if (!group.includes('.'))
|
|
270
|
+
return [parseInt(group, 16)];
|
|
271
|
+
const [a, b, c, d] = group.split('.').map(Number);
|
|
272
|
+
return [(a << 8) | b, (c << 8) | d];
|
|
273
|
+
});
|
|
274
|
+
const headGroups = parse(head);
|
|
275
|
+
const tailGroups = parse(tail);
|
|
276
|
+
if (tail === undefined)
|
|
277
|
+
return headGroups;
|
|
278
|
+
return [...headGroups, ...Array(8 - headGroups.length - tailGroups.length).fill(0), ...tailGroups];
|
|
160
279
|
}
|
|
161
280
|
/**
|
|
162
281
|
* Whether the peer may speak for other clients.
|
|
@@ -192,9 +311,17 @@ function forwardedAddress(entry) {
|
|
|
192
311
|
candidate = canonicalAddress(candidate);
|
|
193
312
|
return isIP(candidate) === 0 ? undefined : candidate;
|
|
194
313
|
}
|
|
195
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* `::ffff:127.0.0.1` and `127.0.0.1` are the same client; key them the same way.
|
|
316
|
+
*
|
|
317
|
+
* Only that spelling, and only when what follows is IPv4: `::ffff:c000:280` stripped to
|
|
318
|
+
* `c000:280` was no address at all, and the request fell back to the proxy's key. Every
|
|
319
|
+
* other spelling of a mapped address reaches its IPv4 key through `keyOf`, by value. The
|
|
320
|
+
* case is left as it came, because this is also the peer the trust check compares with
|
|
321
|
+
* `--trustedProxies` — a zone such as `%WAN` must still match as written.
|
|
322
|
+
*/
|
|
196
323
|
function canonicalAddress(address) {
|
|
197
|
-
return address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
324
|
+
return address.startsWith('::ffff:') && isIP(address.slice(7)) === 4 ? address.slice(7) : address;
|
|
198
325
|
}
|
|
199
326
|
function jsonRpcError(res, status, code, message) {
|
|
200
327
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -216,23 +343,25 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
216
343
|
? new AuthFailureLimiter(authFailureLimit)
|
|
217
344
|
: null;
|
|
218
345
|
// 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
|
|
346
|
+
// client is keyed on the proxy's socket address and so shares one failure budget and one
|
|
347
|
+
// request budget — ten failures from anyone locks out everyone, and one busy client
|
|
348
|
+
// starves the rest. The README tells operators to terminate TLS at a proxy, so this is
|
|
349
|
+
// the configuration it recommends, and the collapse is invisible until a legitimate
|
|
350
|
+
// client is refused.
|
|
223
351
|
let warnedSharedBudget = false;
|
|
224
352
|
const warnSharedBudget = () => {
|
|
225
353
|
if (warnedSharedBudget)
|
|
226
354
|
return;
|
|
227
355
|
warnedSharedBudget = true;
|
|
228
356
|
console.error('POLICY WARNING: X-Forwarded-For is present but not being used to tell clients ' +
|
|
229
|
-
'apart, so every client
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'
|
|
357
|
+
'apart, so every client is charged to one key: one failed-auth budget and one ' +
|
|
358
|
+
'request budget between them, for whichever of the two is on, and one client can ' +
|
|
359
|
+
'lock out or starve the rest. Either --trustProxy is off, or the peer is not a ' +
|
|
360
|
+
'trusted proxy (bare --trustProxy trusts a loopback peer; name others with ' +
|
|
361
|
+
'--trustedProxies), or the rightmost entry is not an address this server can read.');
|
|
233
362
|
};
|
|
234
363
|
const rateLimiter = opts.rateLimit && opts.rateLimit > 0
|
|
235
|
-
? new
|
|
364
|
+
? new ClientRateLimiter(opts.rateLimit)
|
|
236
365
|
: null;
|
|
237
366
|
// DNS rebinding: a page the user visits can make their browser POST to a
|
|
238
367
|
// localhost server, and the bearer token does not help if the browser is
|
|
@@ -284,22 +413,24 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
284
413
|
// is configured — see the handler below for why that second bit is not a secret.
|
|
285
414
|
const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
|
|
286
415
|
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
416
|
let key = '';
|
|
292
|
-
if (authFailureLimiter) {
|
|
417
|
+
if (authFailureLimiter || rateLimiter) {
|
|
293
418
|
const resolved = clientKey(req, opts.trustProxy === true, opts.trustedProxies);
|
|
294
419
|
key = resolved.key;
|
|
295
420
|
// Warned in both directions. Without `--trustProxy` a proxied deployment shares
|
|
296
421
|
// one budget; *with* it, an entry that could not be read leaves the same collapse
|
|
297
422
|
// in place, and that case used to be the silent one — the operator had set the
|
|
298
423
|
// flag and had no way to know it was not taking effect.
|
|
424
|
+
// Resolved for either limiter: with only `--rateLimit` on, skipping this charged
|
|
425
|
+
// every client to one key and kept the warning silent.
|
|
299
426
|
if (resolved.forwardedIgnored || (opts.trustProxy !== true && req.headers['x-forwarded-for'])) {
|
|
300
427
|
warnSharedBudget();
|
|
301
428
|
}
|
|
302
429
|
}
|
|
430
|
+
// Checked before the token is compared, not after — so an exhausted budget answers
|
|
431
|
+
// 429 without evaluating the guess. Gating only the 401 path instead would throttle
|
|
432
|
+
// nothing: the comparison would still happen and a correct token would still be
|
|
433
|
+
// served, so the status code would still tell an attacker which guess was right.
|
|
303
434
|
if (authFailureLimiter) {
|
|
304
435
|
const { allowed, retryAfterMs } = authFailureLimiter.peek(key);
|
|
305
436
|
if (!allowed) {
|
|
@@ -341,23 +472,29 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
341
472
|
}));
|
|
342
473
|
return;
|
|
343
474
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
475
|
+
// Charged after the token check, never before: a request bucket is per client, but
|
|
476
|
+
// unauthenticated traffic reaching it could still spend a victim's budget under a
|
|
477
|
+
// spoofable key, and its 429 would answer a guess without evaluating it. Every
|
|
478
|
+
// authenticated route spends from it, `/status` and 404s included: `/status` was
|
|
479
|
+
// unlimited, so a token holder could poll it without bound.
|
|
480
|
+
if (rateLimiter) {
|
|
481
|
+
const { allowed, retryAfterMs } = rateLimiter.tryConsume(key);
|
|
482
|
+
if (!allowed) {
|
|
483
|
+
const retryAfterSec = Math.ceil(retryAfterMs / 1000);
|
|
484
|
+
res.writeHead(429, {
|
|
485
|
+
'Content-Type': 'application/json',
|
|
486
|
+
'Retry-After': String(retryAfterSec),
|
|
487
|
+
});
|
|
488
|
+
res.end(JSON.stringify({
|
|
489
|
+
jsonrpc: '2.0',
|
|
490
|
+
error: {
|
|
491
|
+
code: -32604,
|
|
492
|
+
message: `Rate limit exceeded. Retry after ${retryAfterSec}s.`,
|
|
493
|
+
},
|
|
494
|
+
id: null,
|
|
495
|
+
}));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
361
498
|
}
|
|
362
499
|
}
|
|
363
500
|
if (req.method === 'POST' && url.pathname === '/') {
|
|
@@ -464,7 +601,7 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
464
601
|
console.error(`SSH MCP Server v2 (HTTP) listening on http://${host}:${port}`);
|
|
465
602
|
console.error('Endpoints: POST / (MCP), GET /status, GET /health');
|
|
466
603
|
if (rateLimiter) {
|
|
467
|
-
console.error(`Rate limit: ${opts.rateLimit} req/min`);
|
|
604
|
+
console.error(`Rate limit: ${opts.rateLimit} req/min per client`);
|
|
468
605
|
}
|
|
469
606
|
if (authFailureLimiter) {
|
|
470
607
|
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.16.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",
|