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 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
  ---
@@ -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
- export function clientKey(req, trustProxy, trustedProxies) {
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 { key: peer, forwardedIgnored: false };
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 { key: peer, forwardedIgnored: true };
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 { key: peer, forwardedIgnored: true };
221
- return { key: address, forwardedIgnored: false };
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
- /** `::ffff:127.0.0.1` and `127.0.0.1` are the same client; key them the same way. */
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.15.0",
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",