tunnel-mcp 0.1.5 → 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,37 @@ 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
+
29
+ ## [0.1.6] - 2026-07-01
30
+
31
+ ### Security
32
+
33
+ - **The auto-downloaded cloudflared binary is now pinned and integrity-verified.**
34
+ Instead of pulling `releases/latest` unverified, tunnel-mcp downloads a pinned
35
+ cloudflared version and checks its SHA-256 against a hash committed in the
36
+ source (and covered by the npm provenance attestation) before the binary is
37
+ extracted, installed, or made executable — so a tampered or wrong-version
38
+ binary is refused, not run. Bump with `scripts/refresh-cloudflared-hashes.mjs`.
39
+ - **Supply-chain hardening of the pipeline:** every GitHub Action is pinned to a
40
+ full commit SHA (not a mutable tag), workflow token permissions default to
41
+ read-only, a `dependency-review` gate blocks PRs that introduce known-vulnerable
42
+ dependencies, and OpenSSF Scorecard + CodeQL scanning run on the repository. See
43
+ the new "Supply chain" section in `SECURITY.md`.
44
+
14
45
  ## [0.1.5] - 2026-07-01
15
46
 
16
47
  ### Changed
@@ -132,7 +163,9 @@ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
132
163
  declaring a fix "confirmed".
133
164
  - Test suite of 109 tests built with vitest, developed test-first (TDD).
134
165
 
135
- [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.5...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
168
+ [0.1.6]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.5...v0.1.6
136
169
  [0.1.5]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.4...v0.1.5
137
170
  [0.1.4]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.3...v0.1.4
138
171
  [0.1.3]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.2...v0.1.3
package/SECURITY.md CHANGED
@@ -83,6 +83,36 @@ share a join link with anyone.
83
83
  `tunnel_close`, an idle timeout (30 minutes with no messages), or the
84
84
  host process exiting. Nothing persists past teardown.
85
85
 
86
+ ## Supply chain
87
+
88
+ tunnel-mcp is a security-sensitive tool, so its build and distribution chain is
89
+ hardened against tampering:
90
+
91
+ - **npm provenance.** Releases are published from GitHub Actions via npm Trusted
92
+ Publishing (OIDC) — no long-lived npm token exists — and every published
93
+ version carries a signed provenance attestation. You can verify a release was
94
+ built from this repository with `npm audit signatures`.
95
+ - **The auto-downloaded cloudflared binary is pinned and verified.** tunnel-mcp
96
+ fetches a specific pinned cloudflared version and checks the artifact's SHA-256
97
+ against a hash committed in the source (and covered by the provenance
98
+ attestation) **before** it is extracted, moved into place, or made executable.
99
+ A mismatched or tampered binary is refused, not run.
100
+ - **Pinned, reviewed dependencies.** Production dependencies are minimal and
101
+ installed from a committed lockfile with integrity hashes (`npm ci`).
102
+ Dependabot proposes updates, and a `dependency-review` gate blocks any pull
103
+ request that would introduce a dependency with a known high-severity
104
+ vulnerability.
105
+ - **Pinned GitHub Actions + least privilege.** Every third-party GitHub Action is
106
+ pinned to a full commit SHA (not a mutable tag), and workflow `GITHUB_TOKEN`
107
+ permissions default to read-only, scoped up only where a job requires it.
108
+ - **Continuous scanning.** OpenSSF Scorecard tracks the repository's
109
+ supply-chain posture, and CodeQL runs static analysis on every push and pull
110
+ request.
111
+
112
+ To bump the pinned cloudflared version, a maintainer runs
113
+ `node scripts/refresh-cloudflared-hashes.mjs <version>` and commits the updated
114
+ version and checksums, keeping the pin auditable.
115
+
86
116
  ## Known Limitations / Threat Model
87
117
 
88
118
  This is an MVP and it is important to be honest about what it does **not**
@@ -1,18 +1,27 @@
1
+ export declare const CLOUDFLARED_VERSION = "2026.6.1";
2
+ export declare const CLOUDFLARED_SHA256: Record<string, string>;
1
3
  export declare function cloudflaredBinName(platform: NodeJS.Platform): string;
4
+ export declare function cloudflaredAsset(platform: NodeJS.Platform, arch: string): string;
2
5
  export declare function cloudflaredDownloadUrl(platform: NodeJS.Platform, arch: string): string;
6
+ export declare function expectedSha256(platform: NodeJS.Platform, arch: string): string;
3
7
  export interface DownloadDeps {
4
8
  fetchImpl?: typeof fetch;
9
+ expectedSha256?: string;
5
10
  }
6
11
  /**
7
12
  * Download cloudflared from `url` and install it at `destBinPath`.
8
13
  *
14
+ * Integrity: when `deps.expectedSha256` is provided, the downloaded artifact is
15
+ * hashed and rejected on any mismatch BEFORE it is extracted, moved into place,
16
+ * or made executable — so a corrupt, tampered, or wrong-version binary never
17
+ * reaches the cache or runs.
18
+ *
9
19
  * Atomic: the binary is downloaded/extracted into a unique location under
10
20
  * os.tmpdir() and only moved into `destBinPath` once it is fully present and
11
- * valid, so a failed/partial download never poisons the destination (callers
12
- * that check `fs.existsSync(destBinPath)` to skip re-downloading are safe).
21
+ * verified, so a failed/partial download never poisons the destination.
13
22
  *
14
- * Any failure anywhere in this path (network, tar extraction, fs ops) is
15
- * caught, temp artifacts (and any partially-installed dest) are cleaned up,
23
+ * Any failure anywhere in this path (network, checksum, tar extraction, fs ops)
24
+ * is caught, temp artifacts (and any partially-installed dest) are cleaned up,
16
25
  * and a single readable error with a manual-install pointer is thrown.
17
26
  */
18
27
  export declare function downloadCloudflared(url: string, destBinPath: string, deps?: DownloadDeps): Promise<void>;
@@ -6,8 +6,23 @@ import { execSync, execFileSync } from 'node:child_process';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { Readable } from 'node:stream';
8
8
  import { BIN_DIR } from '../config.js';
9
- const RELEASE_BASE = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
9
+ // Pin a specific cloudflared release and verify the downloaded artifact against a
10
+ // SHA-256 committed here (and provenance-attested when the package is published),
11
+ // so the tool never executes an unverified binary and never silently picks up a
12
+ // changed "latest". Bump both together with:
13
+ // node scripts/refresh-cloudflared-hashes.mjs <version>
14
+ export const CLOUDFLARED_VERSION = '2026.6.1';
15
+ const RELEASE_BASE = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`;
10
16
  const MANUAL_INSTALL_POINTER = 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
17
+ // SHA-256 of each pinned release asset (Cloudflare does not publish a checksum
18
+ // manifest, so these are computed from the official assets by the refresh script).
19
+ export const CLOUDFLARED_SHA256 = {
20
+ 'cloudflared-darwin-amd64.tgz': 'd7a66b525fe76820da6e5406611b61e48b40de682368ac00454d9158f085be4b',
21
+ 'cloudflared-darwin-arm64.tgz': 'f6d4c439c6c782b83264951d327989ce5e23373acc5942b872411601fedb020d',
22
+ 'cloudflared-linux-amd64': '5861a10a438fe8ddcfebb3b830f83966cbf193edafce0fe2eeb198fbae1f7a22',
23
+ 'cloudflared-linux-arm64': '59816ce9b16db71f5bc2a86d59b3632a96c8c3ee934bde2bc8641ee83a6070eb',
24
+ 'cloudflared-windows-amd64.exe': '5253e66f1f493c4e13539749f1aa86fd0c61e3072900fec29a44ba046a6d97e2',
25
+ };
11
26
  function arch2cf(arch) {
12
27
  if (arch === 'x64')
13
28
  return 'amd64';
@@ -18,16 +33,34 @@ function arch2cf(arch) {
18
33
  export function cloudflaredBinName(platform) {
19
34
  return platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
20
35
  }
21
- export function cloudflaredDownloadUrl(platform, arch) {
36
+ // The release asset filename for this platform/arch — the single key shared by
37
+ // the download URL and the pinned-checksum lookup, so they can never disagree.
38
+ export function cloudflaredAsset(platform, arch) {
22
39
  const a = arch2cf(arch);
23
40
  if (platform === 'darwin')
24
- return `${RELEASE_BASE}/cloudflared-darwin-${a}.tgz`;
41
+ return `cloudflared-darwin-${a}.tgz`;
25
42
  if (platform === 'linux')
26
- return `${RELEASE_BASE}/cloudflared-linux-${a}`;
43
+ return `cloudflared-linux-${a}`;
27
44
  if (platform === 'win32')
28
- return `${RELEASE_BASE}/cloudflared-windows-${a}.exe`;
45
+ return `cloudflared-windows-${a}.exe`;
29
46
  throw new Error(`unsupported platform: ${platform}`);
30
47
  }
48
+ export function cloudflaredDownloadUrl(platform, arch) {
49
+ return `${RELEASE_BASE}/${cloudflaredAsset(platform, arch)}`;
50
+ }
51
+ export function expectedSha256(platform, arch) {
52
+ const asset = cloudflaredAsset(platform, arch);
53
+ const sha = CLOUDFLARED_SHA256[asset];
54
+ if (!sha) {
55
+ throw new Error(`no pinned checksum for ${asset} (cloudflared ${CLOUDFLARED_VERSION})`);
56
+ }
57
+ return sha;
58
+ }
59
+ async function sha256File(p) {
60
+ const hash = crypto.createHash('sha256');
61
+ await pipeline(fs.createReadStream(p), hash);
62
+ return hash.digest('hex');
63
+ }
31
64
  function onPath() {
32
65
  try {
33
66
  const cmd = process.platform === 'win32' ? 'where cloudflared' : 'command -v cloudflared';
@@ -68,13 +101,17 @@ function rmQuiet(p) {
68
101
  /**
69
102
  * Download cloudflared from `url` and install it at `destBinPath`.
70
103
  *
104
+ * Integrity: when `deps.expectedSha256` is provided, the downloaded artifact is
105
+ * hashed and rejected on any mismatch BEFORE it is extracted, moved into place,
106
+ * or made executable — so a corrupt, tampered, or wrong-version binary never
107
+ * reaches the cache or runs.
108
+ *
71
109
  * Atomic: the binary is downloaded/extracted into a unique location under
72
110
  * os.tmpdir() and only moved into `destBinPath` once it is fully present and
73
- * valid, so a failed/partial download never poisons the destination (callers
74
- * that check `fs.existsSync(destBinPath)` to skip re-downloading are safe).
111
+ * verified, so a failed/partial download never poisons the destination.
75
112
  *
76
- * Any failure anywhere in this path (network, tar extraction, fs ops) is
77
- * caught, temp artifacts (and any partially-installed dest) are cleaned up,
113
+ * Any failure anywhere in this path (network, checksum, tar extraction, fs ops)
114
+ * is caught, temp artifacts (and any partially-installed dest) are cleaned up,
78
115
  * and a single readable error with a manual-install pointer is thrown.
79
116
  */
80
117
  export async function downloadCloudflared(url, destBinPath, deps = {}) {
@@ -90,6 +127,13 @@ export async function downloadCloudflared(url, destBinPath, deps = {}) {
90
127
  throw new Error(`cloudflared download failed${status}`);
91
128
  }
92
129
  await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpFile));
130
+ // Verify integrity of the downloaded asset before trusting it in any way.
131
+ if (deps.expectedSha256) {
132
+ const actual = await sha256File(tmpFile);
133
+ if (actual.toLowerCase() !== deps.expectedSha256.toLowerCase()) {
134
+ throw new Error(`checksum mismatch — expected ${deps.expectedSha256}, got ${actual}. Refusing to install a binary that does not match the pinned cloudflared release`);
135
+ }
136
+ }
93
137
  if (url.endsWith('.tgz')) {
94
138
  fs.mkdirSync(tmpExtractDir, { recursive: true });
95
139
  execFileSync('tar', ['-xzf', tmpFile, '-C', tmpExtractDir]); // extracts a `cloudflared` binary
@@ -125,6 +169,8 @@ export async function ensureCloudflared() {
125
169
  return dest;
126
170
  fs.mkdirSync(BIN_DIR, { recursive: true });
127
171
  const url = cloudflaredDownloadUrl(process.platform, process.arch);
128
- await downloadCloudflared(url, dest);
172
+ await downloadCloudflared(url, dest, {
173
+ expectedSha256: expectedSha256(process.platform, process.arch),
174
+ });
129
175
  return dest;
130
176
  }
@@ -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.5",
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
  }