ssh-mcp 2.6.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 +43 -5
- package/build/cli.js +76 -0
- package/build/config/schema.js +9 -1
- package/build/index.js +13 -4
- package/build/policy/classifier.js +13 -1
- package/build/policy/engine.js +74 -7
- package/build/ssh/connection.js +7 -5
- package/build/ssh/host-key.js +64 -3
- package/build/transport/http.js +240 -19
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -487,7 +487,17 @@ When `--opaUrl` is set, commands the built-in engine allows are additionally
|
|
|
487
487
|
evaluated by OPA. **OPA can only narrow.** A command the built-in engine has
|
|
488
488
|
already denied returns that denial without OPA being consulted at all, so a
|
|
489
489
|
sidecar answering `allow` cannot grant a class the role bindings withhold. To
|
|
490
|
-
widen, edit `[policy]`.
|
|
490
|
+
widen, edit `[policy]`.
|
|
491
|
+
|
|
492
|
+
An outage falls back to the local decision and logs one warning per minute. That is the
|
|
493
|
+
default because OPA is an *additional* deny layer and stopping all work would be the worse
|
|
494
|
+
failure — but an operator who deployed OPA *as* the authorization gate loses that gate
|
|
495
|
+
during the outage, and the only signal is a stderr line MCP clients usually discard.
|
|
496
|
+
`--opaFailClosed` makes the gate being down mean no; the refusal carries `ruleId:
|
|
497
|
+
"opa-unavailable"` so the audit record says the gate was down rather than implying a policy
|
|
498
|
+
refused the command.
|
|
499
|
+
|
|
500
|
+
The request shape follows the AuthZEN Access Evaluation contract:
|
|
491
501
|
|
|
492
502
|
```json
|
|
493
503
|
{
|
|
@@ -500,7 +510,7 @@ widen, edit `[policy]`. The request shape follows the AuthZEN Access Evaluation
|
|
|
500
510
|
}
|
|
501
511
|
```
|
|
502
512
|
|
|
503
|
-
OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands (fail-open to avoid locking out access).
|
|
513
|
+
OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands by default (fail-open, to avoid locking out access); `--opaFailClosed` refuses instead. A 200 that carries no boolean `result` counts as unreachable — that is what OPA answers for an undefined document, so a misnamed package or an unactivated bundle is an outage rather than consent.
|
|
504
514
|
|
|
505
515
|
Example Rego policy (`ssh-mcp.rego`):
|
|
506
516
|
```rego
|
|
@@ -618,6 +628,9 @@ ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
|
|
|
618
628
|
| `--httpPort` | 3000 | HTTP listen port |
|
|
619
629
|
| `--httpHost` | 127.0.0.1 | Bind address |
|
|
620
630
|
| `--rateLimit` | 0 (off) | Max requests per minute (0 = unlimited) |
|
|
631
|
+
| `--authFailureLimit` | 10 | Failed bearer-auth attempts allowed per client per minute (0 = off) |
|
|
632
|
+
| `--trustProxy` | false | Read the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer |
|
|
633
|
+
| `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
|
|
621
634
|
|
|
622
635
|
Endpoints: `POST /` (MCP Streamable HTTP), `GET /status`, `GET /health`
|
|
623
636
|
|
|
@@ -629,6 +642,25 @@ bearer token.
|
|
|
629
642
|
|
|
630
643
|
When rate limit is exceeded, the server returns HTTP 429 with `Retry-After` header and a JSON-RPC error body so MCP clients can handle it gracefully.
|
|
631
644
|
|
|
645
|
+
Failed authentication is throttled separately, and on by default. `--rateLimit` never saw a
|
|
646
|
+
wrong bearer token, because the auth check answers before the limiter is reached — so
|
|
647
|
+
guessing ran at network speed. `--authFailureLimit` gives each client its own small budget,
|
|
648
|
+
spent only on a 401; a correct token never consumes from it, so a working client never
|
|
649
|
+
throttles itself. Once an address has spent its budget every request from it waits,
|
|
650
|
+
including one with the right token — that is deliberate, since answering the guess would
|
|
651
|
+
otherwise tell the caller which token was right. Clients are told apart by socket address. **Behind a
|
|
652
|
+
reverse proxy that means every client shares one budget**, so set `--trustProxy` when the
|
|
653
|
+
proxy is yours — the server prints a warning the first time it sees `X-Forwarded-For`
|
|
654
|
+
without it. `--trustProxy` takes the *rightmost* `X-Forwarded-For` entry, which is the hop
|
|
655
|
+
the proxy itself appended; everything to its left came from the client, so reading the
|
|
656
|
+
leftmost would let a client choose its own budget or spend a victim's. That only holds if a
|
|
657
|
+
proxy actually appended the entry, so the header is read **only when the peer is the
|
|
658
|
+
proxy** — bare `--trustProxy` means a loopback peer, which is the deployment above; name a
|
|
659
|
+
proxy elsewhere with `--trustedProxies`. When the header cannot be read as an address, or
|
|
660
|
+
the peer is not trusted, the server falls back to the socket address and says so once, so
|
|
661
|
+
a flag that is not taking effect is not silent. One trusted hop is assumed. A malformed `--authFailureLimit` is refused at startup rather than silently
|
|
662
|
+
disabling the check; only `0` turns it off.
|
|
663
|
+
|
|
632
664
|
**Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
|
|
633
665
|
|
|
634
666
|
---
|
|
@@ -678,13 +710,18 @@ Secrets are **never** passed as CLI arguments.
|
|
|
678
710
|
| `--httpHost` | 127.0.0.1 | HTTP bind address |
|
|
679
711
|
| `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
|
|
680
712
|
| `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
|
|
713
|
+
| `--authFailureLimit` | 10 | Failed bearer-auth attempts allowed per client per minute (0 = off) |
|
|
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
|
+
| `--trustedProxies` | — | Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only |
|
|
681
716
|
| `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
|
|
682
|
-
| `--hostKeyMode` | `tofu` | `tofu \| strict \| insecure`. See [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart)
|
|
683
|
-
| `--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!) |
|
|
684
719
|
| `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
|
|
685
720
|
| `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
|
|
686
721
|
| `--disableApproval` | false | Skip the approval gate (quick start profile only) |
|
|
687
722
|
| `--opaUrl` | — | OPA sidecar URL for external policy |
|
|
723
|
+
| `--opaFailClosed` | false | Refuse every command while OPA is unreachable, instead of falling back to local policy |
|
|
724
|
+
| `--opaTimeoutMs` | 10000 | How long to wait for the OPA sidecar. Lower makes the fail-open cheaper to reach; higher makes an outage slower to notice |
|
|
688
725
|
| `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
|
|
689
726
|
| `--approvalGrantTtl` | 0 (off) | Auto-approve an identical command for this many ms after approval |
|
|
690
727
|
| `--auditEntropyScan` | false | Enable entropy-based secret scanning in audit |
|
|
@@ -751,7 +788,8 @@ process fails the connection. Nothing is written to disk and `~/.ssh/known_hosts
|
|
|
751
788
|
is not consulted, so a restart accepts afresh — see
|
|
752
789
|
[SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart). Pin
|
|
753
790
|
explicitly with `trustedHostKey` in a profile, which is the only control here that
|
|
754
|
-
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).
|
|
755
793
|
|
|
756
794
|
---
|
|
757
795
|
|
package/build/cli.js
CHANGED
|
@@ -59,6 +59,35 @@ export function parseArgv(args = process.argv.slice(2)) {
|
|
|
59
59
|
* `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
|
|
60
60
|
* turned off once written into a wrapper script is its own annoyance.
|
|
61
61
|
*/
|
|
62
|
+
/**
|
|
63
|
+
* `--authFailureLimit`, refusing rather than guessing.
|
|
64
|
+
*
|
|
65
|
+
* `parseArgv` stores `null` for a flag written without a value, and `null !== undefined`,
|
|
66
|
+
* so a bare `--authFailureLimit` reached `parseInt` and produced NaN. NaN fails the `> 0`
|
|
67
|
+
* test the transport uses to decide whether to build the limiter at all, so a typo — or
|
|
68
|
+
* `--authFailureLimit=off`, which `0 = off` makes a plausible spelling — silently turned
|
|
69
|
+
* off a control that is on by default, with no line in the startup banner to say so. The
|
|
70
|
+
* same shape on `--rateLimit` is harmless because that one defaults to off; this one does
|
|
71
|
+
* not, which is what makes failing open new.
|
|
72
|
+
*/
|
|
73
|
+
export function parseFailureLimit(raw) {
|
|
74
|
+
if (raw === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
if (raw === null || !/^\d+$/.test(raw.trim())) {
|
|
77
|
+
throw new OperatorError(`--authFailureLimit needs a non-negative whole number, got ${JSON.stringify(raw)}. ` +
|
|
78
|
+
'Use --authFailureLimit=0 to turn the check off deliberately.');
|
|
79
|
+
}
|
|
80
|
+
const value = Number(raw.trim());
|
|
81
|
+
// Bounded, because an unbounded one is the same fail-open this guard exists to stop:
|
|
82
|
+
// `--authFailureLimit=99999999999999999999` parsed to 1e20, a bucket that never empties
|
|
83
|
+
// and a `Retry-After` of 1 — configured on, functionally absent.
|
|
84
|
+
if (!Number.isSafeInteger(value) || value > MAX_AUTH_FAILURE_LIMIT) {
|
|
85
|
+
throw new OperatorError(`--authFailureLimit must be between 0 and ${MAX_AUTH_FAILURE_LIMIT}, got ${JSON.stringify(raw)}.`);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
/** Far above any real budget; the point is that there is one. */
|
|
90
|
+
const MAX_AUTH_FAILURE_LIMIT = 10_000;
|
|
62
91
|
export function flagEnabled(argv, name) {
|
|
63
92
|
if (!(name in argv))
|
|
64
93
|
return false;
|
|
@@ -99,6 +128,53 @@ export function parseMaxChars(raw) {
|
|
|
99
128
|
return 5_000;
|
|
100
129
|
return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
|
|
101
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* `--opaTimeoutMs`, bounded on both sides.
|
|
133
|
+
*
|
|
134
|
+
* Too low turns an explicit OPA deny into an allow whenever the sidecar is slow, which is
|
|
135
|
+
* the fail-open this budget exists inside; too high brings back the hang it replaced. One
|
|
136
|
+
* second is the floor and two minutes the ceiling.
|
|
137
|
+
*/
|
|
138
|
+
export function parseOpaTimeout(raw) {
|
|
139
|
+
if (raw === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
const value = raw === null ? NaN : Number(raw.trim());
|
|
142
|
+
if (!Number.isSafeInteger(value) || value < 1_000 || value > 120_000) {
|
|
143
|
+
throw new OperatorError(`--opaTimeoutMs must be a whole number of milliseconds between 1000 and 120000, got ${JSON.stringify(raw)}.`);
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* `--opaUrl`, refused at startup rather than at every request.
|
|
149
|
+
*
|
|
150
|
+
* Only emptiness was checked, so anything `fetch` cannot use reached the engine and failed
|
|
151
|
+
* on every call — with `OPA sidecar enabled` printed at startup and one stderr line per
|
|
152
|
+
* minute as the only signal. `--opaUrl=localhost:8181` is a realistic slip, since OPA's own
|
|
153
|
+
* `--addr` is written `:8181`, and in the default mode it leaves a gate that looks
|
|
154
|
+
* configured and is never consulted. Credentials in the URL are refused too: `fetch`
|
|
155
|
+
* rejects such a URL outright, so it would be a gate that cannot work at all.
|
|
156
|
+
*/
|
|
157
|
+
export function parseOpaUrl(raw) {
|
|
158
|
+
if (!raw) {
|
|
159
|
+
throw new OperatorError('--opaUrl needs a URL, as --opaUrl=http://localhost:8181.');
|
|
160
|
+
}
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = new URL(raw);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new OperatorError(`--opaUrl=${raw} is not a URL. Include the scheme, as --opaUrl=http://localhost:8181.`);
|
|
167
|
+
}
|
|
168
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
169
|
+
throw new OperatorError(`--opaUrl must be http or https, got ${parsed.protocol}`);
|
|
170
|
+
}
|
|
171
|
+
if (parsed.username || parsed.password) {
|
|
172
|
+
throw new OperatorError('--opaUrl must not embed credentials — fetch() refuses such a URL, so the gate would ' +
|
|
173
|
+
'never be consulted. Put the sidecar behind a network boundary instead.');
|
|
174
|
+
}
|
|
175
|
+
// A trailing slash would make the request path `//v1/data/...`.
|
|
176
|
+
return (parsed.origin + parsed.pathname).replace(/\/$/, '');
|
|
177
|
+
}
|
|
102
178
|
/**
|
|
103
179
|
* `strict` was previously unreachable: nothing but test code could select it,
|
|
104
180
|
* yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
|
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/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import { reportFatal } from './errors.js';
|
|
4
|
+
import { reportFatal, OperatorError } from './errors.js';
|
|
5
5
|
import { ConnectionRegistry } from './ssh/connection-registry.js';
|
|
6
6
|
import { PolicyEngine, resolvePolicyRules } from './policy/engine.js';
|
|
7
7
|
import { AuditStore } from './audit/store.js';
|
|
8
8
|
import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
|
|
9
9
|
import { initKeychain } from './config/credential-resolver.js';
|
|
10
10
|
import { SERVER_VERSION } from './version.js';
|
|
11
|
-
import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, resolveHostKeyMode, } from './cli.js';
|
|
11
|
+
import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, parseFailureLimit, parseOpaUrl, parseOpaTimeout, resolveHostKeyMode, } from './cli.js';
|
|
12
12
|
async function main() {
|
|
13
13
|
const argv = parseArgv();
|
|
14
14
|
checkRemovedFlags(argv);
|
|
@@ -28,10 +28,16 @@ async function main() {
|
|
|
28
28
|
const registry = new ConnectionRegistry(config, hostKeyMode);
|
|
29
29
|
const policy = new PolicyEngine(resolvePolicyRules(config.profiles, config.policy));
|
|
30
30
|
const audit = new AuditStore(undefined, entropyScan, tamperEvident);
|
|
31
|
-
if (argv
|
|
32
|
-
|
|
31
|
+
if ('opaUrl' in argv) {
|
|
32
|
+
// Presence, not truthiness — the trap `flagEnabled` was written for. A
|
|
33
|
+
// space-separated `--opaUrl http://…` stores null, so the old guard dropped it
|
|
34
|
+
// silently, and dropped any `--opaFailClosed` the operator asked for with it.
|
|
35
|
+
policy.setOpaUrl(parseOpaUrl(argv.opaUrl), flagEnabled(argv, 'opaFailClosed'), parseOpaTimeout(argv.opaTimeoutMs));
|
|
33
36
|
console.error(`OPA sidecar enabled: ${argv.opaUrl}`);
|
|
34
37
|
}
|
|
38
|
+
else if (flagEnabled(argv, 'opaFailClosed')) {
|
|
39
|
+
throw new OperatorError('--opaFailClosed does nothing without --opaUrl.');
|
|
40
|
+
}
|
|
35
41
|
// An McpServer binds to a single transport, so HTTP needs one per session.
|
|
36
42
|
const createMcpServer = () => {
|
|
37
43
|
// capabilities moved from serverInfo to ServerOptions in SDK 1.30.
|
|
@@ -50,6 +56,9 @@ async function main() {
|
|
|
50
56
|
host: argv.httpHost || '127.0.0.1',
|
|
51
57
|
bearerToken: argv.bearerToken,
|
|
52
58
|
rateLimit: parseInt(argv.rateLimit) || 0,
|
|
59
|
+
authFailureLimit: parseFailureLimit(argv.authFailureLimit),
|
|
60
|
+
trustProxy: flagEnabled(argv, 'trustProxy'),
|
|
61
|
+
trustedProxies: argv.trustedProxies?.split(',').map((h) => h.trim()).filter(Boolean),
|
|
53
62
|
allowedHosts: argv.allowedHosts?.split(',').map((h) => h.trim()).filter(Boolean),
|
|
54
63
|
registry,
|
|
55
64
|
});
|
|
@@ -82,7 +82,19 @@ const FORBIDDEN_PATTERNS = [
|
|
|
82
82
|
/rm\s+-rf?\s+\/(\s|$)/, // rm -rf / — the filesystem root itself
|
|
83
83
|
/mkfs\./,
|
|
84
84
|
/>\s*\/dev\/sd/,
|
|
85
|
-
|
|
85
|
+
// Fork bomb, with whitespace allowed at the five positions bash allows it and the old
|
|
86
|
+
// pattern did not: before the parentheses, inside them, either side of the pipe, and
|
|
87
|
+
// before the ampersand. `: () { : | : & } ; :` ran and classified `safe`.
|
|
88
|
+
//
|
|
89
|
+
// What this still does not catch, so that nobody reads it as "fork bombs are handled":
|
|
90
|
+
// the same bomb under another name (`f(){ f|f& };f`), a body that separates the two
|
|
91
|
+
// calls with `&` or `;` rather than `|` (`:(){ :&:& };:`), and a comment between the
|
|
92
|
+
// tokens (`:() # x\n{ :|:& };:`). Matching the first needs a backreference over an
|
|
93
|
+
// unbounded body and this file has already shipped one ReDoS; the others would widen a
|
|
94
|
+
// list no role, tier or approval mode can override. The impact is a denial of service
|
|
95
|
+
// against the target host rather than against this server, which is not worth either
|
|
96
|
+
// trade — but it is a hole, not a closed door.
|
|
97
|
+
/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
|
|
86
98
|
/>\s*\/etc\/cron/,
|
|
87
99
|
/>\s*\/etc\/systemd/,
|
|
88
100
|
/>\s*~\/.ssh\/authorized_keys/,
|
package/build/policy/engine.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { classifyCommand, findForbiddenMatch } from './classifier.js';
|
|
2
2
|
import { OperatorError } from '../errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* How long to wait for the OPA sidecar before treating it as unavailable.
|
|
5
|
+
*
|
|
6
|
+
* Bounded because an unbounded wait held every tool call for undici's five-minute header
|
|
7
|
+
* timeout. Ten seconds and not two: in the default mode a timeout falls back to the local
|
|
8
|
+
* decision, so the budget is also the price of turning an explicit OPA *deny* into an
|
|
9
|
+
* allow — at two seconds a sidecar under load crosses it by accident, and anyone who can
|
|
10
|
+
* add latency crosses it on purpose. Ten is above a loaded sidecar's p99 and still thirty
|
|
11
|
+
* times cheaper than the hang it replaced. `--opaTimeoutMs` moves it: lower makes the
|
|
12
|
+
* fail-open cheaper to reach, higher makes an outage slower to notice.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_OPA_TIMEOUT_MS = 10_000;
|
|
3
15
|
/** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
|
|
4
16
|
export const HOST_GROUPS = ['prod', 'staging', 'dev'];
|
|
5
17
|
export const DEFAULT_RULES = {
|
|
@@ -184,6 +196,8 @@ export function resolvePolicyRules(profiles, override) {
|
|
|
184
196
|
export class PolicyEngine {
|
|
185
197
|
rules;
|
|
186
198
|
opaUrl = null;
|
|
199
|
+
opaFailClosed = false;
|
|
200
|
+
opaTimeoutMs = DEFAULT_OPA_TIMEOUT_MS;
|
|
187
201
|
/** Rate-limits the fail-open warning so one outage can't flood stderr. */
|
|
188
202
|
lastOpaWarning = 0;
|
|
189
203
|
/** The operator's patterns, compiled once. The built-ins live in classifier.ts. */
|
|
@@ -202,8 +216,15 @@ export class PolicyEngine {
|
|
|
202
216
|
}
|
|
203
217
|
});
|
|
204
218
|
}
|
|
205
|
-
setOpaUrl(url) {
|
|
219
|
+
setOpaUrl(url, failClosed = false, timeoutMs = DEFAULT_OPA_TIMEOUT_MS) {
|
|
220
|
+
this.opaTimeoutMs = timeoutMs;
|
|
221
|
+
// Reset the warning throttle when the mode changes, or the one warning per minute can
|
|
222
|
+
// be the *previous* mode's sentence — "commands OPA would deny may now be allowed"
|
|
223
|
+
// printed while the engine refuses everything.
|
|
224
|
+
if (failClosed !== this.opaFailClosed || url !== this.opaUrl)
|
|
225
|
+
this.lastOpaWarning = 0;
|
|
206
226
|
this.opaUrl = url;
|
|
227
|
+
this.opaFailClosed = failClosed;
|
|
207
228
|
}
|
|
208
229
|
evaluate(command, profile, _toolName) {
|
|
209
230
|
const parsed = classifyCommand(command);
|
|
@@ -277,13 +298,31 @@ export class PolicyEngine {
|
|
|
277
298
|
method: 'POST',
|
|
278
299
|
headers: { 'Content-Type': 'application/json' },
|
|
279
300
|
body: JSON.stringify({ input }),
|
|
301
|
+
// A sidecar that accepts the connection and never answers is the canonical way
|
|
302
|
+
// OPA is "unreachable", and without this the call ran to undici's five-minute
|
|
303
|
+
// header timeout while every tool call waited on it. The abort lands in the catch
|
|
304
|
+
// below, so both modes reach their own answer inside the budget.
|
|
305
|
+
signal: AbortSignal.timeout(this.opaTimeoutMs),
|
|
280
306
|
});
|
|
281
307
|
if (!resp.ok) {
|
|
282
|
-
this.
|
|
283
|
-
return local;
|
|
308
|
+
return this.onOpaUnavailable(`HTTP ${resp.status}`, local);
|
|
284
309
|
}
|
|
285
|
-
const
|
|
286
|
-
|
|
310
|
+
const payload = await resp.json();
|
|
311
|
+
// Guarded, not cast. `as { result?: unknown }` is a lie for a `null` body — reading
|
|
312
|
+
// `.result` off it threw, so the cause on stderr read as a defect in this server
|
|
313
|
+
// rather than as the "no decision" condition it names below.
|
|
314
|
+
const result = typeof payload === 'object' && payload !== null
|
|
315
|
+
? payload.result
|
|
316
|
+
: undefined;
|
|
317
|
+
if (typeof result !== 'boolean') {
|
|
318
|
+
// A 200 carrying no decision is how OPA answers for an undefined document, which
|
|
319
|
+
// is what a wrong package path, an unactivated bundle, or an `allow` rule written
|
|
320
|
+
// without `default allow := false` all produce. Reading that as consent meant
|
|
321
|
+
// `--opaFailClosed` did not cover the way an OPA gate actually goes down: six of
|
|
322
|
+
// seven response shapes allowed, with no warning at all.
|
|
323
|
+
return this.onOpaUnavailable('no boolean `result` in the response', local);
|
|
324
|
+
}
|
|
325
|
+
if (result === false) {
|
|
287
326
|
return {
|
|
288
327
|
decision: 'deny',
|
|
289
328
|
commandClass: parsed.class,
|
|
@@ -294,10 +333,36 @@ export class PolicyEngine {
|
|
|
294
333
|
}
|
|
295
334
|
}
|
|
296
335
|
catch (err) {
|
|
297
|
-
this.
|
|
336
|
+
return this.onOpaUnavailable(err instanceof Error ? err.message : String(err), local);
|
|
298
337
|
}
|
|
299
338
|
return local;
|
|
300
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* What an OPA outage means, which is the operator's choice rather than ours.
|
|
342
|
+
*
|
|
343
|
+
* Falling back to the local decision is the default and stays the default: OPA is an
|
|
344
|
+
* additional deny layer, and an outage that stopped all work would be a worse failure
|
|
345
|
+
* than one that narrows the policy. But an operator who deployed OPA *as* the
|
|
346
|
+
* authorization gate has a control that disappears during an outage, and the only signal
|
|
347
|
+
* is a stderr line that MCP clients usually discard. `--opaFailClosed` lets them say
|
|
348
|
+
* that the gate being down means no.
|
|
349
|
+
*/
|
|
350
|
+
onOpaUnavailable(cause, local) {
|
|
351
|
+
this.warnOpaUnavailable(cause);
|
|
352
|
+
if (!this.opaFailClosed)
|
|
353
|
+
return local;
|
|
354
|
+
return {
|
|
355
|
+
decision: 'deny',
|
|
356
|
+
commandClass: local.commandClass,
|
|
357
|
+
binary: local.binary,
|
|
358
|
+
ruleId: 'opa-unavailable',
|
|
359
|
+
// Fixed string. `cause` goes to stderr, where the operator is; this `reason` is
|
|
360
|
+
// rethrown as the tool's error text and written to the audit record, and it carried
|
|
361
|
+
// `this.opaUrl` — an internal hostname, and any credential embedded in it — out to
|
|
362
|
+
// the MCP client on every refusal.
|
|
363
|
+
reason: 'OPA evaluation unavailable and --opaFailClosed is set',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
301
366
|
/**
|
|
302
367
|
* OPA is an *additional* deny layer, so an outage falls back to the local
|
|
303
368
|
* decision rather than blocking all work. That is a deliberate trade-off, but
|
|
@@ -311,7 +376,9 @@ export class PolicyEngine {
|
|
|
311
376
|
return;
|
|
312
377
|
this.lastOpaWarning = now;
|
|
313
378
|
console.error(`POLICY WARNING: OPA evaluation unavailable (${cause}). ` +
|
|
314
|
-
|
|
379
|
+
(this.opaFailClosed
|
|
380
|
+
? 'Refusing every command while it is down, because --opaFailClosed is set.'
|
|
381
|
+
: 'Falling back to local policy — commands OPA would deny may now be allowed.'));
|
|
315
382
|
}
|
|
316
383
|
/**
|
|
317
384
|
* Say which three things produced the refusal, not one of them.
|
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/build/transport/http.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'http';
|
|
2
2
|
import { randomUUID, timingSafeEqual } from 'crypto';
|
|
3
|
+
import { isIP } from 'net';
|
|
3
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
5
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
5
6
|
import { SERVER_VERSION } from '../version.js';
|
|
@@ -7,32 +8,193 @@ import { OperatorError } from '../errors.js';
|
|
|
7
8
|
const MAX_BODY_SIZE = 1_048_576; // 1MB
|
|
8
9
|
/** Cap on concurrent MCP sessions, so unauthenticated-adjacent churn can't grow the map without bound. */
|
|
9
10
|
const MAX_SESSIONS = 64;
|
|
11
|
+
/** How many failed auth attempts one client may make per minute. 0 disables the check. */
|
|
12
|
+
export const DEFAULT_AUTH_FAILURE_LIMIT = 10;
|
|
13
|
+
/** Cap on tracked clients, for the reason `MAX_SESSIONS` exists: churn must not grow a map. */
|
|
14
|
+
export const MAX_TRACKED_CLIENTS = 1024;
|
|
15
|
+
const REFILL_INTERVAL_MS = 60_000;
|
|
16
|
+
/**
|
|
17
|
+
* One token-bucket step, shared by the two limiters so the refill arithmetic exists once.
|
|
18
|
+
*
|
|
19
|
+
* Mutates the bucket. Refills proportionally to elapsed time rather than on a timer, so an
|
|
20
|
+
* idle server costs nothing and there is no interval to clean up.
|
|
21
|
+
*/
|
|
22
|
+
function consume(bucket, maxTokens) {
|
|
23
|
+
const elapsed = Date.now() - bucket.lastRefill;
|
|
24
|
+
const refilled = Math.floor((elapsed / REFILL_INTERVAL_MS) * maxTokens);
|
|
25
|
+
if (refilled > 0) {
|
|
26
|
+
bucket.tokens = Math.min(maxTokens, bucket.tokens + refilled);
|
|
27
|
+
bucket.lastRefill += Math.round((refilled / maxTokens) * REFILL_INTERVAL_MS);
|
|
28
|
+
}
|
|
29
|
+
if (bucket.tokens > 0) {
|
|
30
|
+
bucket.tokens--;
|
|
31
|
+
return { allowed: true, retryAfterMs: 0 };
|
|
32
|
+
}
|
|
33
|
+
return { allowed: false, retryAfterMs: Math.ceil(REFILL_INTERVAL_MS / maxTokens) };
|
|
34
|
+
}
|
|
10
35
|
class RateLimiter {
|
|
11
|
-
tokens;
|
|
12
|
-
lastRefill;
|
|
13
36
|
maxTokens;
|
|
14
|
-
|
|
15
|
-
constructor(
|
|
16
|
-
this.maxTokens =
|
|
17
|
-
this.
|
|
18
|
-
this.lastRefill = Date.now();
|
|
19
|
-
this.refillIntervalMs = 60_000;
|
|
37
|
+
bucket;
|
|
38
|
+
constructor(maxTokens) {
|
|
39
|
+
this.maxTokens = maxTokens;
|
|
40
|
+
this.bucket = { tokens: maxTokens, lastRefill: Date.now() };
|
|
20
41
|
}
|
|
21
42
|
tryConsume() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
return consume(this.bucket, this.maxTokens);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A bucket per client, for throttling failed authentication.
|
|
48
|
+
*
|
|
49
|
+
* Separate from the request limiter on purpose. The auth check returned before the request
|
|
50
|
+
* limiter was reached, so a wrong bearer token consumed nothing and guessing ran at network
|
|
51
|
+
* speed with no backoff — measured as twelve 401s and zero 429s against `--rateLimit=3`.
|
|
52
|
+
* Moving the request limiter above the auth check would have closed that and opened
|
|
53
|
+
* something worse: the request bucket is global, so unauthenticated traffic could then
|
|
54
|
+
* starve every legitimate client.
|
|
55
|
+
*
|
|
56
|
+
* Only failures consume a token, so a working client never builds a budget up and is never
|
|
57
|
+
* throttled by its own traffic — which is what makes this safe to have on by default. It
|
|
58
|
+
* is not a promise that a correct token always passes: once an address has spent its
|
|
59
|
+
* budget, everything from that address waits, correct tokens included. That is the whole
|
|
60
|
+
* point. Charging only after the comparison would leave the guess itself evaluated and the
|
|
61
|
+
* status code would still tell the attacker which token was right.
|
|
62
|
+
*/
|
|
63
|
+
export class AuthFailureLimiter {
|
|
64
|
+
maxTokens;
|
|
65
|
+
buckets = new Map();
|
|
66
|
+
constructor(maxTokens) {
|
|
67
|
+
this.maxTokens = maxTokens;
|
|
68
|
+
}
|
|
69
|
+
/** Whether this client may make another attempt. Does not consume. */
|
|
70
|
+
peek(key) {
|
|
71
|
+
const bucket = this.buckets.get(key);
|
|
72
|
+
if (bucket === undefined)
|
|
73
|
+
return { allowed: true, retryAfterMs: 0 };
|
|
74
|
+
const elapsed = Date.now() - bucket.lastRefill;
|
|
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)
|
|
31
78
|
return { allowed: true, retryAfterMs: 0 };
|
|
79
|
+
return { allowed: false, retryAfterMs: Math.ceil(REFILL_INTERVAL_MS / this.maxTokens) };
|
|
80
|
+
}
|
|
81
|
+
/** Charge this client for a failed attempt. */
|
|
82
|
+
recordFailure(key) {
|
|
83
|
+
let bucket = this.buckets.get(key);
|
|
84
|
+
if (bucket === undefined) {
|
|
85
|
+
bucket = { tokens: this.maxTokens, lastRefill: Date.now() };
|
|
86
|
+
if (this.buckets.size >= MAX_TRACKED_CLIENTS) {
|
|
87
|
+
// Evict the *fullest* entry, not the oldest. An exhausted bucket has the oldest
|
|
88
|
+
// `lastRefill` by construction, so evicting by age evicted the locked-out client
|
|
89
|
+
// first and then handed its key a fresh budget: minting enough keys cleared a
|
|
90
|
+
// lockout, which was measured end to end. A full bucket is the one with nothing
|
|
91
|
+
// worth remembering.
|
|
92
|
+
let fullestKey = null;
|
|
93
|
+
let fullest = -1;
|
|
94
|
+
for (const [k, b] of this.buckets) {
|
|
95
|
+
if (b.tokens > fullest) {
|
|
96
|
+
fullest = b.tokens;
|
|
97
|
+
fullestKey = k;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (fullestKey !== null)
|
|
101
|
+
this.buckets.delete(fullestKey);
|
|
102
|
+
// Every tracked client is spent, so the table itself is the signal and a new key
|
|
103
|
+
// does not get a full budget.
|
|
104
|
+
//
|
|
105
|
+
// The cost, stated because it is real and cannot be softened here: while the table
|
|
106
|
+
// is saturated an arriving client has one attempt rather than the full budget, so
|
|
107
|
+
// a first typo leaves its correct token waiting. Seeding one token instead of zero
|
|
108
|
+
// looks like it would help and does not — `consume` spends it immediately, and
|
|
109
|
+
// `peek` already allows a key it has never seen, so the first attempt is free
|
|
110
|
+
// either way and the second is refused either way. Measured both, identical.
|
|
111
|
+
// 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
|
+
if (fullest <= 0)
|
|
114
|
+
bucket = { tokens: 0, lastRefill: Date.now() };
|
|
115
|
+
}
|
|
116
|
+
this.buckets.set(key, bucket);
|
|
32
117
|
}
|
|
33
|
-
|
|
34
|
-
|
|
118
|
+
consume(bucket, this.maxTokens);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Which client an attempt is charged to.
|
|
123
|
+
*
|
|
124
|
+
* The socket's remote address, which is the real client on a direct connection — how this
|
|
125
|
+
* server is normally run. `X-Forwarded-For` is read only when a proxy is explicitly
|
|
126
|
+
* trusted: honouring it unconditionally would let any client forge its own key and so opt
|
|
127
|
+
* out of the limit entirely.
|
|
128
|
+
*
|
|
129
|
+
* When it is read, the **rightmost** entry is the one taken, not the leftmost. A proxy
|
|
130
|
+
* appends the address it saw, so the rightmost entry is what the nearest trusted proxy
|
|
131
|
+
* observed while every entry to its left came from the client. Reading the leftmost was
|
|
132
|
+
* worse than having no limit: a client sending `X-Forwarded-For: 10.0.0.1` minted a fresh
|
|
133
|
+
* budget per request — measured as nine wrong tokens and zero 429s — and sending a
|
|
134
|
+
* victim's address burned *their* budget instead, locking out a correct token.
|
|
135
|
+
*
|
|
136
|
+
* This assumes exactly one trusted proxy in front, which is what `--trustProxy` means. A
|
|
137
|
+
* chain of two would need the second-from-right, and this does not try to guess the depth.
|
|
138
|
+
* Anything that is not an IP address is discarded rather than used as a map key.
|
|
139
|
+
*/
|
|
140
|
+
export function clientKey(req, trustProxy, trustedProxies) {
|
|
141
|
+
const peer = canonicalAddress(req.socket.remoteAddress ?? 'unknown');
|
|
142
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
143
|
+
if (!trustProxy || forwarded === undefined)
|
|
144
|
+
return { key: peer, forwardedIgnored: false };
|
|
145
|
+
// The rightmost entry is proxy-authored only if a proxy actually appended one. Nothing
|
|
146
|
+
// about the header says whether it did, so the *peer* has to be the proxy — otherwise a
|
|
147
|
+
// client reaching the listener directly sends one forged entry, that entry is the
|
|
148
|
+
// rightmost, and it picks its own key. Both attacks this keying was fixed to stop came
|
|
149
|
+
// back alive in exactly that configuration.
|
|
150
|
+
if (!isTrustedPeer(peer, trustedProxies)) {
|
|
151
|
+
return { key: peer, forwardedIgnored: true };
|
|
152
|
+
}
|
|
153
|
+
const raw = Array.isArray(forwarded) ? forwarded.join(',') : forwarded;
|
|
154
|
+
const entries = raw.split(',').map((e) => e.trim()).filter(Boolean);
|
|
155
|
+
const nearest = entries[entries.length - 1];
|
|
156
|
+
const address = nearest === undefined ? undefined : forwardedAddress(nearest);
|
|
157
|
+
if (address === undefined)
|
|
158
|
+
return { key: peer, forwardedIgnored: true };
|
|
159
|
+
return { key: address, forwardedIgnored: false };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Whether the peer may speak for other clients.
|
|
163
|
+
*
|
|
164
|
+
* Bare `--trustProxy` means a loopback peer, which is the deployment the README asks for:
|
|
165
|
+
* the server binds to 127.0.0.1 and a proxy on the same host terminates TLS. A proxy
|
|
166
|
+
* somewhere else has to be named, because "trust whoever connected" is the assumption that
|
|
167
|
+
* made the header forgeable again.
|
|
168
|
+
*/
|
|
169
|
+
function isTrustedPeer(peer, trustedProxies) {
|
|
170
|
+
if (trustedProxies !== undefined && trustedProxies.length > 0) {
|
|
171
|
+
return trustedProxies.includes(peer);
|
|
172
|
+
}
|
|
173
|
+
return peer === '127.0.0.1' || peer === '::1' || peer.startsWith('127.');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* One `X-Forwarded-For` entry as an address, or undefined if it is not one.
|
|
177
|
+
*
|
|
178
|
+
* `net.isIP` rejects the bracketed and port-suffixed spellings, which is how IPv6 usually
|
|
179
|
+
* appears in this header — and rejecting silently collapsed every client onto the proxy's
|
|
180
|
+
* key, which is worse than not keying at all.
|
|
181
|
+
*/
|
|
182
|
+
function forwardedAddress(entry) {
|
|
183
|
+
let candidate = entry;
|
|
184
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(candidate);
|
|
185
|
+
if (bracketed)
|
|
186
|
+
candidate = bracketed[1];
|
|
187
|
+
// A trailing `:port` only when what is left is not itself an IPv6 literal: `::1` has
|
|
188
|
+
// colons of its own and must not be truncated.
|
|
189
|
+
else if (isIP(candidate) === 0 && /^[^:]+:\d+$/.test(candidate)) {
|
|
190
|
+
candidate = candidate.slice(0, candidate.lastIndexOf(':'));
|
|
35
191
|
}
|
|
192
|
+
candidate = canonicalAddress(candidate);
|
|
193
|
+
return isIP(candidate) === 0 ? undefined : candidate;
|
|
194
|
+
}
|
|
195
|
+
/** `::ffff:127.0.0.1` and `127.0.0.1` are the same client; key them the same way. */
|
|
196
|
+
function canonicalAddress(address) {
|
|
197
|
+
return address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
36
198
|
}
|
|
37
199
|
function jsonRpcError(res, status, code, message) {
|
|
38
200
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -49,6 +211,26 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
49
211
|
throw new OperatorError('HTTP transport requires --bearerToken. Example: --transport=http --bearerToken=secret\n' +
|
|
50
212
|
'Without authentication, any network client can execute SSH commands on your hosts.');
|
|
51
213
|
}
|
|
214
|
+
const authFailureLimit = opts.authFailureLimit ?? DEFAULT_AUTH_FAILURE_LIMIT;
|
|
215
|
+
const authFailureLimiter = authFailureLimit > 0
|
|
216
|
+
? new AuthFailureLimiter(authFailureLimit)
|
|
217
|
+
: null;
|
|
218
|
+
// 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 — which means
|
|
220
|
+
// ten failures from anyone locks out everyone. The README tells operators to terminate
|
|
221
|
+
// TLS at a proxy, so this is the configuration it recommends, and the collapse is
|
|
222
|
+
// invisible until a legitimate client is refused.
|
|
223
|
+
let warnedSharedBudget = false;
|
|
224
|
+
const warnSharedBudget = () => {
|
|
225
|
+
if (warnedSharedBudget)
|
|
226
|
+
return;
|
|
227
|
+
warnedSharedBudget = true;
|
|
228
|
+
console.error('POLICY WARNING: X-Forwarded-For is present but not being used to tell clients ' +
|
|
229
|
+
'apart, so every client shares one failed-auth budget and one failing client can ' +
|
|
230
|
+
'lock out the rest. Either --trustProxy is off, or the peer is not a trusted proxy ' +
|
|
231
|
+
'(bare --trustProxy trusts a loopback peer; name others with --trustedProxies), or ' +
|
|
232
|
+
'the rightmost entry is not an address this server can read.');
|
|
233
|
+
};
|
|
52
234
|
const rateLimiter = opts.rateLimit && opts.rateLimit > 0
|
|
53
235
|
? new RateLimiter(opts.rateLimit)
|
|
54
236
|
: null;
|
|
@@ -102,6 +284,41 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
102
284
|
// is configured — see the handler below for why that second bit is not a secret.
|
|
103
285
|
const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
|
|
104
286
|
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
|
+
let key = '';
|
|
292
|
+
if (authFailureLimiter) {
|
|
293
|
+
const resolved = clientKey(req, opts.trustProxy === true, opts.trustedProxies);
|
|
294
|
+
key = resolved.key;
|
|
295
|
+
// Warned in both directions. Without `--trustProxy` a proxied deployment shares
|
|
296
|
+
// one budget; *with* it, an entry that could not be read leaves the same collapse
|
|
297
|
+
// in place, and that case used to be the silent one — the operator had set the
|
|
298
|
+
// flag and had no way to know it was not taking effect.
|
|
299
|
+
if (resolved.forwardedIgnored || (opts.trustProxy !== true && req.headers['x-forwarded-for'])) {
|
|
300
|
+
warnSharedBudget();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (authFailureLimiter) {
|
|
304
|
+
const { allowed, retryAfterMs } = authFailureLimiter.peek(key);
|
|
305
|
+
if (!allowed) {
|
|
306
|
+
const retryAfterSec = Math.ceil(retryAfterMs / 1000);
|
|
307
|
+
res.writeHead(429, {
|
|
308
|
+
'Content-Type': 'application/json',
|
|
309
|
+
'Retry-After': String(retryAfterSec),
|
|
310
|
+
});
|
|
311
|
+
res.end(JSON.stringify({
|
|
312
|
+
jsonrpc: '2.0',
|
|
313
|
+
error: {
|
|
314
|
+
code: -32604,
|
|
315
|
+
message: `Too many failed authentication attempts. Retry after ${retryAfterSec}s.`,
|
|
316
|
+
},
|
|
317
|
+
id: null,
|
|
318
|
+
}));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
105
322
|
const auth = req.headers.authorization || '';
|
|
106
323
|
const expected = `Bearer ${bearerToken}`;
|
|
107
324
|
const authBuf = Buffer.from(auth);
|
|
@@ -109,6 +326,7 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
109
326
|
const match = authBuf.length === expectedBuf.length &&
|
|
110
327
|
timingSafeEqual(authBuf, expectedBuf);
|
|
111
328
|
if (!match) {
|
|
329
|
+
authFailureLimiter?.recordFailure(key);
|
|
112
330
|
// RFC 7235: a 401 must say how to authenticate. MCP clients also parse
|
|
113
331
|
// JSON-RPC envelopes on this route, so 401 speaks the same dialect as
|
|
114
332
|
// the 429 and 413 responses rather than a bare {error}.
|
|
@@ -248,6 +466,9 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
248
466
|
if (rateLimiter) {
|
|
249
467
|
console.error(`Rate limit: ${opts.rateLimit} req/min`);
|
|
250
468
|
}
|
|
469
|
+
if (authFailureLimiter) {
|
|
470
|
+
console.error(`Auth failure limit: ${authFailureLimit}/min per client`);
|
|
471
|
+
}
|
|
251
472
|
resolve();
|
|
252
473
|
});
|
|
253
474
|
});
|
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",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"zod": "^4.4.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@changesets/changelog-github": "^0.
|
|
42
|
-
"@changesets/cli": "^
|
|
41
|
+
"@changesets/changelog-github": "^1.0.0",
|
|
42
|
+
"@changesets/cli": "^3.0.1",
|
|
43
43
|
"@types/node": "^26.2.0",
|
|
44
44
|
"@types/ssh2": "^1.15.5",
|
|
45
45
|
"@vitest/coverage-v8": "^4.1.10",
|