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 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.6...HEAD
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
- return JSON.parse(data);
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
- let frame;
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 = decodeFrame(data.toString());
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
- return;
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.6",
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": "^2.0.0",
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": "^2.0.0"
78
+ "vitest": "^4.1.9"
78
79
  }
79
80
  }