ssh-mcp 2.15.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 +8 -0
- package/build/transport/http.js +73 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -806,6 +806,14 @@ not taking effect is not silent. One trusted hop is assumed. A malformed
|
|
|
806
806
|
`--authFailureLimit` is refused at startup rather than silently disabling the check; only
|
|
807
807
|
`0` turns it off.
|
|
808
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.
|
|
816
|
+
|
|
809
817
|
**Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
|
|
810
818
|
|
|
811
819
|
---
|
package/build/transport/http.js
CHANGED
|
@@ -181,7 +181,17 @@ export class AuthFailureLimiter {
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
/**
|
|
184
|
-
* 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.
|
|
185
195
|
*
|
|
186
196
|
* The socket's remote address, which is the real client on a direct connection — how this
|
|
187
197
|
* server is normally run. `X-Forwarded-For` is read only when a proxy is explicitly
|
|
@@ -199,26 +209,73 @@ export class AuthFailureLimiter {
|
|
|
199
209
|
* chain of two would need the second-from-right, and this does not try to guess the depth.
|
|
200
210
|
* Anything that is not an IP address is discarded rather than used as a map key.
|
|
201
211
|
*/
|
|
202
|
-
|
|
212
|
+
function chargedAddress(req, trustProxy, trustedProxies) {
|
|
203
213
|
const peer = canonicalAddress(req.socket.remoteAddress ?? 'unknown');
|
|
204
214
|
const forwarded = req.headers['x-forwarded-for'];
|
|
205
215
|
if (!trustProxy || forwarded === undefined)
|
|
206
|
-
return {
|
|
216
|
+
return { address: peer, forwardedIgnored: false };
|
|
207
217
|
// The rightmost entry is proxy-authored only if a proxy actually appended one. Nothing
|
|
208
218
|
// about the header says whether it did, so the *peer* has to be the proxy — otherwise a
|
|
209
219
|
// client reaching the listener directly sends one forged entry, that entry is the
|
|
210
220
|
// rightmost, and it picks its own key. Both attacks this keying was fixed to stop came
|
|
211
221
|
// back alive in exactly that configuration.
|
|
212
222
|
if (!isTrustedPeer(peer, trustedProxies)) {
|
|
213
|
-
return {
|
|
223
|
+
return { address: peer, forwardedIgnored: true };
|
|
214
224
|
}
|
|
215
225
|
const raw = Array.isArray(forwarded) ? forwarded.join(',') : forwarded;
|
|
216
226
|
const entries = raw.split(',').map((e) => e.trim()).filter(Boolean);
|
|
217
227
|
const nearest = entries[entries.length - 1];
|
|
218
228
|
const address = nearest === undefined ? undefined : forwardedAddress(nearest);
|
|
219
229
|
if (address === undefined)
|
|
220
|
-
return {
|
|
221
|
-
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];
|
|
222
279
|
}
|
|
223
280
|
/**
|
|
224
281
|
* Whether the peer may speak for other clients.
|
|
@@ -254,9 +311,17 @@ function forwardedAddress(entry) {
|
|
|
254
311
|
candidate = canonicalAddress(candidate);
|
|
255
312
|
return isIP(candidate) === 0 ? undefined : candidate;
|
|
256
313
|
}
|
|
257
|
-
/**
|
|
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
|
+
*/
|
|
258
323
|
function canonicalAddress(address) {
|
|
259
|
-
return address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
324
|
+
return address.startsWith('::ffff:') && isIP(address.slice(7)) === 4 ? address.slice(7) : address;
|
|
260
325
|
}
|
|
261
326
|
function jsonRpcError(res, status, code, message) {
|
|
262
327
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
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",
|