tunnel-mcp 0.1.6 → 0.1.7
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/CHANGELOG.md +17 -1
- package/dist/protocol/messages.js +12 -1
- package/dist/relay/guestClient.js +39 -29
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
11
11
|
|
|
12
12
|
- Nothing yet.
|
|
13
13
|
|
|
14
|
+
## [0.1.7] - 2026-07-01
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- **Hardened the untrusted-input decoders against malformed frames.** Added
|
|
19
|
+
property-based fuzzing (`fast-check`) of `decrypt`, `decodeFrame`, and
|
|
20
|
+
`parseLink`, which surfaced two robustness bugs — both now fixed:
|
|
21
|
+
- `decodeFrame` did a blind `JSON.parse(...) as ControlFrame`; a payload that
|
|
22
|
+
parsed to `null` or a primitive could crash a downstream `frame.t` access. It
|
|
23
|
+
now rejects anything that isn't a proper frame object with a string `t`.
|
|
24
|
+
- The guest's WebSocket message handler only guarded frame decoding, so a
|
|
25
|
+
malformed frame from an untrusted host (e.g. an `auth_ok` with no `backlog`)
|
|
26
|
+
could throw uncaught and crash the guest. The whole handler is now defended,
|
|
27
|
+
matching the host relay.
|
|
28
|
+
|
|
14
29
|
## [0.1.6] - 2026-07-01
|
|
15
30
|
|
|
16
31
|
### Security
|
|
@@ -148,7 +163,8 @@ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
|
|
|
148
163
|
declaring a fix "confirmed".
|
|
149
164
|
- Test suite of 109 tests built with vitest, developed test-first (TDD).
|
|
150
165
|
|
|
151
|
-
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.
|
|
166
|
+
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.7...HEAD
|
|
167
|
+
[0.1.7]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.6...v0.1.7
|
|
152
168
|
[0.1.6]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.5...v0.1.6
|
|
153
169
|
[0.1.5]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.4...v0.1.5
|
|
154
170
|
[0.1.4]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.3...v0.1.4
|
|
@@ -30,6 +30,17 @@ export function decrypt(msg, key) {
|
|
|
30
30
|
export function encodeFrame(frame) {
|
|
31
31
|
return JSON.stringify(frame);
|
|
32
32
|
}
|
|
33
|
+
// Validate enough of the shape that the `as ControlFrame` cast is honest: every
|
|
34
|
+
// caller switches on `frame.t`, so a parsed value that is null, a primitive, an
|
|
35
|
+
// array, or lacks a string `t` must be rejected here (callers wrap this in
|
|
36
|
+
// try/catch) rather than handed back as a frame that crashes `frame.t`.
|
|
33
37
|
export function decodeFrame(data) {
|
|
34
|
-
|
|
38
|
+
const parsed = JSON.parse(data);
|
|
39
|
+
if (parsed === null ||
|
|
40
|
+
typeof parsed !== 'object' ||
|
|
41
|
+
Array.isArray(parsed) ||
|
|
42
|
+
typeof parsed.t !== 'string') {
|
|
43
|
+
throw new Error('malformed control frame');
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
35
46
|
}
|
|
@@ -58,38 +58,48 @@ export class GuestClient extends EventEmitter {
|
|
|
58
58
|
reject(e);
|
|
59
59
|
};
|
|
60
60
|
ws.on('message', (data) => {
|
|
61
|
-
|
|
61
|
+
// The host is untrusted: a malformed or schema-invalid frame must never
|
|
62
|
+
// crash the guest. decodeFrame guarantees a string `t`, but a variant's
|
|
63
|
+
// fields (e.g. auth_ok.backlog) are still unchecked, so defend the whole
|
|
64
|
+
// handler like the host relay does. A swallowed frame just stalls, and
|
|
65
|
+
// the overall connect deadline rejects on a stall.
|
|
62
66
|
try {
|
|
63
|
-
frame
|
|
67
|
+
let frame;
|
|
68
|
+
try {
|
|
69
|
+
frame = decodeFrame(data.toString());
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (frame.t === 'challenge') {
|
|
75
|
+
ws.send(encodeFrame({
|
|
76
|
+
t: 'auth',
|
|
77
|
+
response: respondChallenge(frame.nonce, this.link.key),
|
|
78
|
+
name: this.guestName,
|
|
79
|
+
sinceSeq,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
else if (frame.t === 'auth_ok') {
|
|
83
|
+
for (const m of frame.backlog)
|
|
84
|
+
this.log.record(m);
|
|
85
|
+
settleResolve({ goal: frame.goal, peerName: frame.peerName });
|
|
86
|
+
}
|
|
87
|
+
else if (frame.t === 'auth_fail') {
|
|
88
|
+
settleReject(new Error(`auth failed: ${frame.reason}`));
|
|
89
|
+
ws.close();
|
|
90
|
+
}
|
|
91
|
+
else if (frame.t === 'msg') {
|
|
92
|
+
this.log.record(frame.msg);
|
|
93
|
+
const waiter = this.pending.get(frame.msg.id);
|
|
94
|
+
if (waiter) {
|
|
95
|
+
this.pending.delete(frame.msg.id);
|
|
96
|
+
waiter.resolve(frame.msg.seq);
|
|
97
|
+
}
|
|
98
|
+
this.emit('message', frame.msg);
|
|
99
|
+
}
|
|
64
100
|
}
|
|
65
101
|
catch {
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
if (frame.t === 'challenge') {
|
|
69
|
-
ws.send(encodeFrame({
|
|
70
|
-
t: 'auth',
|
|
71
|
-
response: respondChallenge(frame.nonce, this.link.key),
|
|
72
|
-
name: this.guestName,
|
|
73
|
-
sinceSeq,
|
|
74
|
-
}));
|
|
75
|
-
}
|
|
76
|
-
else if (frame.t === 'auth_ok') {
|
|
77
|
-
for (const m of frame.backlog)
|
|
78
|
-
this.log.record(m);
|
|
79
|
-
settleResolve({ goal: frame.goal, peerName: frame.peerName });
|
|
80
|
-
}
|
|
81
|
-
else if (frame.t === 'auth_fail') {
|
|
82
|
-
settleReject(new Error(`auth failed: ${frame.reason}`));
|
|
83
|
-
ws.close();
|
|
84
|
-
}
|
|
85
|
-
else if (frame.t === 'msg') {
|
|
86
|
-
this.log.record(frame.msg);
|
|
87
|
-
const waiter = this.pending.get(frame.msg.id);
|
|
88
|
-
if (waiter) {
|
|
89
|
-
this.pending.delete(frame.msg.id);
|
|
90
|
-
waiter.resolve(frame.msg.seq);
|
|
91
|
-
}
|
|
92
|
-
this.emit('message', frame.msg);
|
|
102
|
+
/* untrusted-frame guard: ignore anything malformed */
|
|
93
103
|
}
|
|
94
104
|
});
|
|
95
105
|
ws.on('close', () => this.failPending(new Error('tunnel disconnected')));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tunnel-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Let two developers' Claude agents talk directly through an ephemeral, end-to-end-encrypted tunnel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -68,12 +68,13 @@
|
|
|
68
68
|
"@anthropic-ai/sdk": "^0.109.0",
|
|
69
69
|
"@types/node": "^20.14.0",
|
|
70
70
|
"@types/ws": "^8.5.10",
|
|
71
|
-
"@vitest/coverage-v8": "^
|
|
71
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
72
72
|
"eslint": "^9.9.0",
|
|
73
|
+
"fast-check": "^4.8.0",
|
|
73
74
|
"prettier": "^3.3.0",
|
|
74
75
|
"tsx": "^4.16.0",
|
|
75
76
|
"typescript": "^5.5.0",
|
|
76
77
|
"typescript-eslint": "^8.0.0",
|
|
77
|
-
"vitest": "^
|
|
78
|
+
"vitest": "^4.1.9"
|
|
78
79
|
}
|
|
79
80
|
}
|