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 +34 -1
- package/SECURITY.md +30 -0
- package/dist/cloudflared/provision.d.ts +13 -4
- package/dist/cloudflared/provision.js +56 -10
- 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,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.
|
|
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
|
-
*
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
41
|
+
return `cloudflared-darwin-${a}.tgz`;
|
|
25
42
|
if (platform === 'linux')
|
|
26
|
-
return
|
|
43
|
+
return `cloudflared-linux-${a}`;
|
|
27
44
|
if (platform === 'win32')
|
|
28
|
-
return
|
|
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
|
-
*
|
|
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)
|
|
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
|
-
|
|
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
|
}
|