ssh-mcp 2.7.0 → 2.8.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 +4 -3
- package/build/config/schema.js +9 -1
- package/build/ssh/connection.js +7 -5
- package/build/ssh/host-key.js +64 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -714,8 +714,8 @@ Secrets are **never** passed as CLI arguments.
|
|
|
714
714
|
| `--trustProxy` | false | Read the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer |
|
|
715
715
|
| `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
|
|
716
716
|
| `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
|
|
717
|
-
| `--hostKeyMode` | `tofu` | `tofu \| strict \| insecure`. See [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart)
|
|
718
|
-
| `--insecureHostKey` | false | Disable host key verification (test only!) |
|
|
717
|
+
| `--hostKeyMode` | `tofu` | `tofu \| strict \| insecure`. `strict` accepts only hosts pinned with `trustedHostKey`. See [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart) |
|
|
718
|
+
| `--insecureHostKey` | false | Disable host key verification for hosts with no `trustedHostKey` — a pin still refuses (test only!) |
|
|
719
719
|
| `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
|
|
720
720
|
| `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
|
|
721
721
|
| `--disableApproval` | false | Skip the approval gate (quick start profile only) |
|
|
@@ -788,7 +788,8 @@ process fails the connection. Nothing is written to disk and `~/.ssh/known_hosts
|
|
|
788
788
|
is not consulted, so a restart accepts afresh — see
|
|
789
789
|
[SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart). Pin
|
|
790
790
|
explicitly with `trustedHostKey` in a profile, which is the only control here that
|
|
791
|
-
survives a restart
|
|
791
|
+
survives a restart — and which no host key mode overrides, so `--insecureHostKey`
|
|
792
|
+
is an opt-out only for hosts you have not pinned (test environments only).
|
|
792
793
|
|
|
793
794
|
---
|
|
794
795
|
|
package/build/config/schema.js
CHANGED
|
@@ -115,7 +115,15 @@ export const profileSchema = z.object({
|
|
|
115
115
|
// profile name, and an unrecognised name resolves to the strictest tier.
|
|
116
116
|
group: z.string().optional(),
|
|
117
117
|
workdir: z.string().optional(),
|
|
118
|
-
|
|
118
|
+
// `.trim().min(1)` rather than a bare string. `trustedHostKey = ""` passed
|
|
119
|
+
// validation and then did nothing: the old gate in connection.ts tested it for
|
|
120
|
+
// truthiness, so an empty pin was silently inert and the operator who wrote the
|
|
121
|
+
// line believed they were pinned. A blank pin is always a mistake, and a loud
|
|
122
|
+
// one at startup beats a profile that quietly verifies nothing. The trim is for
|
|
123
|
+
// the same reason in the other direction — the comparison against a fingerprint
|
|
124
|
+
// is exact, so a trailing newline from a heredoc or an editor refused every
|
|
125
|
+
// key with no hint as to why.
|
|
126
|
+
trustedHostKey: z.string().trim().min(1, 'trustedHostKey cannot be empty').optional(),
|
|
119
127
|
tty: z.boolean().default(false),
|
|
120
128
|
role: z.string().default('operator'),
|
|
121
129
|
readOnly: z.boolean().default(false),
|
package/build/ssh/connection.js
CHANGED
|
@@ -161,11 +161,13 @@ export class SSHConnection {
|
|
|
161
161
|
username: this.profile.user,
|
|
162
162
|
algorithms: FROZEN_ALGORITHMS,
|
|
163
163
|
hostVerifier: (key) => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
164
|
+
// The pin used to be checked here, as a `return false` ahead of
|
|
165
|
+
// verifyHostKey. That made it reject-only: a *matching* pin fell
|
|
166
|
+
// through and was refused anyway under `strict`, whose store can never
|
|
167
|
+
// be populated. Passing it in puts one function in charge of the whole
|
|
168
|
+
// decision, and `return false` gave ssh2 a generic handshake failure
|
|
169
|
+
// where verifyHostKey raises a message that names what decided.
|
|
170
|
+
return verifyHostKey(this.profile.host, this.profile.port, fingerprintPublicKey(key), this.knownHostsStore, this.hostKeyMode, this.profile.trustedHostKey);
|
|
169
171
|
},
|
|
170
172
|
readyTimeout: 20000,
|
|
171
173
|
keepaliveInterval: 15000,
|
package/build/ssh/host-key.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
import { OperatorError } from '../errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Shared as a literal, not through a message builder: the three refusals below
|
|
5
|
+
* diverge in their advice, and message-quality.test.ts asserts each one's wording
|
|
6
|
+
* separately. Only the command is common, and only it should be.
|
|
7
|
+
*/
|
|
8
|
+
const CONFIRM_OUT_OF_BAND = '`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server';
|
|
3
9
|
export function fingerprintPublicKey(key) {
|
|
4
10
|
const hash = createHash('sha256').update(key).digest('base64');
|
|
5
11
|
// `={0,2}` rather than `=+`: base64 padding is never longer than two
|
|
@@ -8,10 +14,65 @@ export function fingerprintPublicKey(key) {
|
|
|
8
14
|
// quantifier is the shape worth not copying elsewhere.
|
|
9
15
|
return `SHA256:${hash.replace(/={0,2}$/, '')}`;
|
|
10
16
|
}
|
|
11
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Decide whether to accept the key a host just presented.
|
|
19
|
+
*
|
|
20
|
+
* `knownHosts` is one Map shared by every profile (connection-registry.ts), which
|
|
21
|
+
* is why a pin never *reads* it: consulting it for a pinned host would make two
|
|
22
|
+
* profiles pinning different keys for one host:port collide with a false
|
|
23
|
+
* HOST_KEY_MISMATCH, once the host presents each its own key.
|
|
24
|
+
*
|
|
25
|
+
* Why `--hostKeyMode=strict` refused every host before 2.8.0 is in
|
|
26
|
+
* .changeset/lucky-keys-pinned.md and SECURITY.md; the branch order here is what
|
|
27
|
+
* that history left behind.
|
|
28
|
+
*
|
|
29
|
+
* @param pinnedFingerprint `trustedHostKey` from the profile. Falsy means no pin.
|
|
30
|
+
* A value decides the outcome in every mode, `insecure` included. It is never
|
|
31
|
+
* compared against `knownHosts`, but a match does record into it under `tofu` —
|
|
32
|
+
* see the pin branch for why those two differ.
|
|
33
|
+
*/
|
|
34
|
+
export function verifyHostKey(host, port, fingerprint, knownHosts, mode, pinnedFingerprint) {
|
|
35
|
+
const key = `${host}:${port}`;
|
|
36
|
+
// Before `insecure`, not after: the gate this replaced sat ahead of
|
|
37
|
+
// verifyHostKey in connection.ts, so a key contradicting the pin was refused in
|
|
38
|
+
// every mode, `insecure` included. Pinned by host-key.test.ts, "refuses a key
|
|
39
|
+
// that contradicts the pin, in every mode including insecure".
|
|
40
|
+
//
|
|
41
|
+
// Truthiness, not `!== undefined`: `trustedHostKey = ""` was inert on 2.7.0
|
|
42
|
+
// because that gate tested truthiness, and `!== undefined` would turn the same
|
|
43
|
+
// config into "refuse every host". The schema rejects a blank pin, so this is
|
|
44
|
+
// defence in depth rather than the primary guard.
|
|
45
|
+
if (pinnedFingerprint) {
|
|
46
|
+
if (pinnedFingerprint !== fingerprint) {
|
|
47
|
+
// No "or turn the check off" exit here, unlike the two refusals below.
|
|
48
|
+
// Nothing overrides a pin — not tofu, not insecure — so offering a mode
|
|
49
|
+
// would describe a way out that does not exist.
|
|
50
|
+
throw new OperatorError(`HOST_KEY_PIN_MISMATCH: the key presented by ${host}:${port} is not the pinned key.\n` +
|
|
51
|
+
` pinned ${pinnedFingerprint}\n` +
|
|
52
|
+
` received ${fingerprint}\n` +
|
|
53
|
+
'trustedHostKey on this profile is what decided, and no host key mode overrides it. ' +
|
|
54
|
+
'Either the pin is stale — the server was rebuilt or its address reused, in which case ' +
|
|
55
|
+
`confirm the new fingerprint out of band (${CONFIRM_OUT_OF_BAND}) ` +
|
|
56
|
+
'and update trustedHostKey — or something is intercepting this connection, ' +
|
|
57
|
+
'which is what pinning exists to catch.');
|
|
58
|
+
}
|
|
59
|
+
// Matched. Two constraints on the write, neither derivable from the code:
|
|
60
|
+
//
|
|
61
|
+
// `tofu` only — under `strict` the store is the only thing that admits an
|
|
62
|
+
// *unpinned* profile, so seeding it there would let one profile's pin authorise
|
|
63
|
+
// another. Fill-only — an unconditional set makes the entry order-dependent for
|
|
64
|
+
// an unpinned sibling, and 2.7.0's write was reachable only on a store miss, so
|
|
65
|
+
// this writes exactly when that one would have.
|
|
66
|
+
//
|
|
67
|
+
// And it is inside the match, below the throw: recording on the refusal path
|
|
68
|
+
// would put an interceptor's fingerprint into the shared store, which is the
|
|
69
|
+
// failure the write exists to prevent.
|
|
70
|
+
if (mode === 'tofu' && !knownHosts.has(key))
|
|
71
|
+
knownHosts.set(key, fingerprint);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
12
74
|
if (mode === 'insecure')
|
|
13
75
|
return true;
|
|
14
|
-
const key = `${host}:${port}`;
|
|
15
76
|
const stored = knownHosts.get(key);
|
|
16
77
|
if (stored) {
|
|
17
78
|
if (stored !== fingerprint) {
|
|
@@ -24,7 +85,7 @@ export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
|
|
|
24
85
|
'Either the server was rebuilt, reinstalled or had its address reused — in which case the new key is ' +
|
|
25
86
|
'genuine — or something is intercepting this connection, which is what this check exists to catch. ' +
|
|
26
87
|
'Nothing here can tell those apart; confirm the fingerprint out of band, from the host itself ' +
|
|
27
|
-
|
|
88
|
+
`(${CONFIRM_OUT_OF_BAND}) or from whoever rebuilt it.\n` +
|
|
28
89
|
'If it matches, pin it with trustedHostKey on the profile. Do not reach for --insecureHostKey to get ' +
|
|
29
90
|
'past this: it disables verification for every host and every future connection, including the case ' +
|
|
30
91
|
'this message is warning you about.');
|
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.8.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",
|