Skip to content

Commit 6bb81a8

Browse files
ARHAEEMclaude
andcommitted
fix(security): tunnel revocation, read-only mislabel, config truncation
Three HIGH findings from the audit. Each was reproduced before fixing and each is pinned by a regression test that fails against the old code. daemon: the daemon held TWO independent activeTunnel handles -- one in server.js (filled by POST /daemon/enable-tunnel) and one in launcher.js (filled by the boot auto-start) -- and neither closure could see the other. disable-tunnel was a silent no-op on a boot-started tunnel: it skipped stop(), nulled the lockfile tunnelUrl and returned ok while cloudflared kept serving the public hostname. The 401-burst tripwire delegated to the launcher callback and so could never stop a dashboard-enabled tunnel. And enable-tunnel's stop-the-existing guard was equally blind, orphaning a second cloudflared. getHealth() reads the server closure, so /daemon/health and manage_daemon status corroborated the false state. /mcp still required the 256-bit timing-safe bearer, so this is failed revocation rather than open access -- but with /mcp?token= secret URLs the URL is the credential. startDaemonServer now exposes adoptTunnel()/getActiveTunnel(), the launcher hands its handle over, and the tripwire stops that single handle directly. Also consumes the boot tunnel's waitUntilReady rejection. It is created eagerly in tunnel.js and rejected when cloudflared exits before publishing a URL; nothing consumed it and there is no unhandledRejection handler, so Node's default throw killed the daemon ~1s after it began serving. index.js: download_formula_field and download_base_formulas were annotated readOnlyHint:true while writing files at a caller-supplied path. readOnlyHint is the signal MCP clients use to auto-approve without prompting, so the annotation removed the consent step from a filesystem write. Now readOnlyHint:false, destructiveHint:true. Separately, the filename sanitiser stripped separators but not '.', so a table named '..' wrote every file one directory above outputDir -- table names come from the base and are attacker influenced. Both segments now go through confineToDir(). fieldName is newline-stripped in the # AT: header, which description already was. lsp-config.ts: unconfigureMcpToml/unconfigureHelix truncated the user's config from our marker to EOF instead of removing our block. Both configureMcpToml and `codex mcp add` append at EOF, so a second MCP server added after Setup sat below ours and was destroyed by Unconfigure -- no confirmation, no backup, no error. Measured on the old code, a config.toml with one extra server was reduced to a single newline. Now removes only our own sections; HELIX_BLOCK is four tables, not one, and the header matcher will not mistake a multi-line array continuation for a header. Verified: 1442 mcp-server + 421 extension + 88 webview tests pass, check:tool-sync green, pnpm build succeeds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bda7e32 commit 6bb81a8

8 files changed

Lines changed: 414 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how
66

77
## [Unreleased]
88

9+
### Fixed — Unconfigure destroyed unrelated config in Codex / Helix files (2026-07-31)
10+
11+
- **`unconfigureMcpToml` and `unconfigureHelix` truncated the user's config file from our marker
12+
to EOF.** Both did `existing.slice(0, indexOf(MARKER))`, discarding everything below our block
13+
rather than removing only our block. Since `configureMcpToml` appends at EOF and `codex mcp add`
14+
does too, any second MCP server or `[model_providers.*]` section the user added after running
15+
Setup sat below ours — and clicking **Unconfigure** in the dashboard (no confirmation prompt)
16+
destroyed it silently, with no backup and no error. Measured against the old code, a
17+
`config.toml` holding one extra MCP server was reduced to a **single newline**.
18+
Both now remove only the sections we own, preserving everything else verbatim.
19+
Note `HELIX_BLOCK` is *four* top-level tables, not one, so a naive "delete to the next `[`
20+
header" would have orphaned three `[[language]]` blocks; the header matcher is also strict
21+
enough not to mistake a continuation line of a multi-line array (`matrix = [\n[1,2],\n]`) for a
22+
table header. Pinned by `src/test/lsp-config-toml.test.ts`.
23+
924
### Fixed — formatter tokenizers silently corrupted formulas / hung the extension host (2026-07-31)
1025

1126
Found by audit, both reproduced before fixing.

packages/extension/src/auto-config/lsp-config.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,16 @@ async function unconfigureHelix(configPath: string): Promise<void> {
8080
let existing = '';
8181
try { existing = await fs.promises.readFile(configPath, 'utf8'); } catch { return; }
8282
if (!existing.includes(HELIX_MARKER)) return;
83-
// Remove everything from HELIX_MARKER to the next blank-line-separated section
84-
const idx = existing.indexOf(HELIX_MARKER);
85-
const before = existing.slice(0, idx).trimEnd();
86-
await writeTextAtomic(configPath, before + '\n');
83+
// HELIX_BLOCK is FOUR top-level tables, not one: the language-server plus a
84+
// [[language]] per file type. Remove each of ours and nothing else — a
85+
// "delete to the next header" cut would orphan the three [[language]] blocks,
86+
// and the old "delete to EOF" cut destroyed the user's own config below ours.
87+
const next = removeTomlSections(existing, (s) => {
88+
if (s.header === null) return false;
89+
if (s.header === HELIX_MARKER) return true;
90+
return s.header === '[[language]]' && s.text.includes(`language-servers = ["${LSP_SERVER_KEY}"]`);
91+
});
92+
await writeTextAtomic(configPath, next);
8793
}
8894

8995
async function isHelixConfigured(configPath: string): Promise<boolean> {
@@ -175,9 +181,12 @@ export async function unconfigureMcpToml(configPath: string): Promise<void> {
175181
let existing = '';
176182
try { existing = await fs.promises.readFile(configPath, 'utf8'); } catch { return; }
177183
if (!existing.includes(CODEX_MARKER)) return;
178-
const idx = existing.indexOf(CODEX_MARKER);
179-
const before = existing.slice(0, idx).trimEnd();
180-
await writeTextAtomic(configPath, before + '\n');
184+
// Remove only our own table. This used to truncate the file from the marker to
185+
// EOF — and since both `configureMcpToml` and `codex mcp add` append at EOF,
186+
// any MCP server or [model_providers.*] block the user added after Setup sat
187+
// below ours and was silently destroyed by clicking Unconfigure.
188+
const next = removeTomlSections(existing, (s) => s.header === CODEX_MARKER);
189+
await writeTextAtomic(configPath, next);
181190
}
182191

183192
export async function isMcpTomlConfigured(configPath: string): Promise<boolean> {
@@ -187,6 +196,51 @@ export async function isMcpTomlConfigured(configPath: string): Promise<boolean>
187196
} catch { return false; }
188197
}
189198

199+
// ── Shared TOML section surgery ─────────────────────────────────────────────
200+
201+
/**
202+
* A complete top-level TOML table header on its own line: `[table]`, `[[array]]`,
203+
* optionally dotted/quoted, optionally trailing comment.
204+
*
205+
* Deliberately strict. A loose `/^\s*\[/` also matches a continuation line of a
206+
* multi-line array (`matrix = [\n[1,2],\n]`), which would split a user's value in
207+
* half and corrupt the file — the exact class of bug this helper exists to fix.
208+
*/
209+
const TOML_HEADER_RE = /^[ \t]*\[\[?[A-Za-z0-9_.\-"' ]+\]\]?[ \t]*(?:#.*)?$/;
210+
211+
interface TomlSection { header: string | null; text: string }
212+
213+
/** Split a TOML document into a preamble plus one entry per top-level header. */
214+
function splitTomlSections(text: string): TomlSection[] {
215+
const sections: TomlSection[] = [];
216+
let current: TomlSection = { header: null, text: '' };
217+
for (const line of text.split('\n')) {
218+
if (TOML_HEADER_RE.test(line)) {
219+
sections.push(current);
220+
current = { header: line.trim(), text: line + '\n' };
221+
} else {
222+
current.text += line + '\n';
223+
}
224+
}
225+
sections.push(current);
226+
return sections.filter((s, i) => i === 0 || s.header !== null);
227+
}
228+
229+
/**
230+
* Drop only the sections we own, preserving everything else verbatim.
231+
*
232+
* The previous implementation did `existing.slice(0, indexOf(MARKER))` — i.e. it
233+
* truncated the user's file from our marker to EOF, destroying any config that
234+
* happened to sit below ours. Since `configureMcpToml` appends at EOF and
235+
* `codex mcp add` does too, a second MCP server added after Setup was silently
236+
* deleted by clicking Unconfigure.
237+
*/
238+
function removeTomlSections(text: string, isOurs: (s: TomlSection) => boolean): string {
239+
const kept = splitTomlSections(text).filter((s) => !isOurs(s));
240+
const out = kept.map((s) => s.text).join('').replace(/\n{3,}/g, '\n\n').trimEnd();
241+
return out === '' ? '' : out + '\n';
242+
}
243+
190244
// ── Shared text-file atomic writer ──────────────────────────────────────────
191245

192246
async function writeTextAtomic(filePath: string, content: string): Promise<void> {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import * as fs from 'node:fs';
3+
import * as os from 'node:os';
4+
import * as path from 'node:path';
5+
import {
6+
configureMcpToml,
7+
unconfigureMcpToml,
8+
isMcpTomlConfigured,
9+
} from '../auto-config/lsp-config.js';
10+
11+
let dir: string;
12+
let cfg: string;
13+
14+
beforeEach(() => {
15+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atf-toml-'));
16+
cfg = path.join(dir, 'config.toml');
17+
});
18+
afterEach(() => {
19+
fs.rmSync(dir, { recursive: true, force: true });
20+
});
21+
22+
describe('unconfigureMcpToml preserves user config', () => {
23+
// The bug: unconfigure did `existing.slice(0, indexOf(MARKER))`, truncating the
24+
// file from our marker to EOF. Both configureMcpToml and `codex mcp add`
25+
// append at EOF, so anything the user added after Setup sat below ours.
26+
it('keeps a second MCP server added BELOW our block', async () => {
27+
await configureMcpToml(cfg);
28+
fs.appendFileSync(
29+
cfg,
30+
'\n[mcp_servers.some-other-server]\ntype = "stdio"\ncommand = "other"\nargs = ["--flag"]\n',
31+
);
32+
33+
await unconfigureMcpToml(cfg);
34+
const out = fs.readFileSync(cfg, 'utf8');
35+
36+
expect(out).toContain('[mcp_servers.some-other-server]');
37+
expect(out).toContain('command = "other"');
38+
expect(out).not.toContain('airtable-user-mcp');
39+
expect(await isMcpTomlConfigured(cfg)).toBe(false);
40+
});
41+
42+
it('keeps config that sits ABOVE our block', async () => {
43+
fs.writeFileSync(cfg, 'model = "gpt-5"\n\n[model_providers.openai]\nbase_url = "https://x"\n');
44+
await configureMcpToml(cfg);
45+
await unconfigureMcpToml(cfg);
46+
const out = fs.readFileSync(cfg, 'utf8');
47+
48+
expect(out).toContain('model = "gpt-5"');
49+
expect(out).toContain('[model_providers.openai]');
50+
expect(out).toContain('base_url = "https://x"');
51+
expect(out).not.toContain('airtable-user-mcp');
52+
});
53+
54+
it('does not split a multi-line array whose continuation lines start with [', async () => {
55+
// A loose /^\s*\[/ header match would treat `[1, 2],` as a table header and
56+
// cut the user's value in half.
57+
fs.writeFileSync(cfg, 'matrix = [\n[1, 2],\n[3, 4],\n]\n');
58+
await configureMcpToml(cfg);
59+
await unconfigureMcpToml(cfg);
60+
const out = fs.readFileSync(cfg, 'utf8');
61+
62+
expect(out).toContain('matrix = [');
63+
expect(out).toContain('[1, 2],');
64+
expect(out).toContain('[3, 4],');
65+
expect(out).not.toContain('airtable-user-mcp');
66+
});
67+
68+
it('round-trips to an equivalent file', async () => {
69+
const original = 'model = "gpt-5"\n\n[model_providers.openai]\nbase_url = "https://x"\n';
70+
fs.writeFileSync(cfg, original);
71+
await configureMcpToml(cfg);
72+
expect(await isMcpTomlConfigured(cfg)).toBe(true);
73+
await unconfigureMcpToml(cfg);
74+
expect(fs.readFileSync(cfg, 'utf8').trim()).toBe(original.trim());
75+
});
76+
77+
it('is a no-op when we were never configured', async () => {
78+
fs.writeFileSync(cfg, 'model = "gpt-5"\n');
79+
await unconfigureMcpToml(cfg);
80+
expect(fs.readFileSync(cfg, 'utf8')).toBe('model = "gpt-5"\n');
81+
});
82+
});

packages/mcp-server/CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,51 @@
22

33
## [Unreleased]
44

5+
### Fixed (2026-07-31 — tunnel revocation reached only half the cases)
6+
7+
- **The daemon held TWO independent `activeTunnel` handles**, one in `daemon/server.js` (filled by
8+
`POST /daemon/enable-tunnel`) and one in `daemon/launcher.js` (filled by the boot auto-start).
9+
Neither closure could see the other, so every path that stops a tunnel reached only half the
10+
cases: `POST /daemon/disable-tunnel` was a **silent no-op on a boot-started tunnel** — it
11+
skipped `stop()`, nulled the lockfile `tunnelUrl`, published `daemon:tunnel-stopped` and
12+
returned `{ok:true}` while cloudflared kept serving the public hostname; the **401-burst
13+
tripwire** delegated to the launcher's callback and so could never stop a dashboard-enabled
14+
tunnel (it latched `tunnelAutoDisabled`, the UI showed "Auto-disabled", the URL stayed live);
15+
and `enable-tunnel`'s stop-the-existing guard was equally blind, leaving **two cloudflared
16+
children and two public URLs**. `getHealth()` reads the server closure, so `/daemon/health` and
17+
`manage_daemon action=status` corroborated the false state. `/mcp` still required the 256-bit
18+
timing-safe bearer throughout, so this was a **failed revocation control**, not open access —
19+
but with `/mcp?token=` secret URLs the URL *is* the shared credential. `startDaemonServer()` now
20+
exposes `adoptTunnel()`/`getActiveTunnel()`, the launcher hands its boot-started handle over,
21+
and the tripwire stops that single handle directly. Pinned by `test/test-tunnel-ownership.test.js`.
22+
- **A boot-auto-started tunnel could kill the daemon by unhandled rejection.** `waitUntilReady` is
23+
created eagerly in `tunnel.js` and rejected when cloudflared exits before publishing a URL
24+
(offline boot, blocked egress, a trycloudflare 429). Nothing consumed it and the repo installs
25+
no `process.on('unhandledRejection')`, so Node's default throw took the daemon down ~1s after it
26+
began serving, killing in-flight MCP requests and orphaning the non-detached LSP child. Now
27+
consumed and logged.
28+
29+
### Security (2026-07-31 — `download_*` tools declared read-only while writing files)
30+
31+
- **`download_formula_field` and `download_base_formulas` were annotated `readOnlyHint: true`,
32+
`destructiveHint: false`** while writing (and overwriting) files at a caller-supplied path.
33+
`readOnlyHint` is exactly the signal MCP clients use to **auto-approve a call without
34+
prompting**, so the annotation removed the user's consent step from a filesystem write. Both are
35+
now `readOnlyHint: false, destructiveHint: true`. *(Their `read` category is unchanged and
36+
remains a judgement call — see the note below.)*
37+
- **A table named `..` escaped the chosen output directory.** The filename sanitiser stripped
38+
separators (`/\:*?"<>|`) but not `.`, so `download_base_formulas` wrote every formula file one
39+
directory *above* `outputDir`. Table and field names come from the base, so they are attacker
40+
influenced by anyone who can edit a base the user can read. Both segments now go through
41+
`confineToDir()`, which rejects dot-only segments and asserts the resolved path stays under the
42+
chosen directory.
43+
- **`fieldName` was interpolated into the `# AT:` header with only quotes escaped**, so a newline
44+
in a field name injected arbitrary header lines (`description` was already stripped). Both sites
45+
now use `headerSafe()`.
46+
- Not changed: `outputPath`/`outputDir` themselves are still honoured as given. They are the
47+
user's explicit choice, and confining them to `cwd` would break legitimate "save to my Desktop"
48+
use; the consent step is restored by the annotation fix instead.
49+
550
### Security (2026-07-31 — row-template IDs were missing the path-traversal guard)
651

752
- **All seven row-template client methods interpolated a caller-supplied `templateId` into the

packages/mcp-server/src/daemon/launcher.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,12 @@ export async function startDaemon(options = {}) {
364364
syncLockfile(nextToken.bearerToken);
365365
},
366366
onTunnelAutoDisable: async ({ failures, windowMs, ip }) => {
367-
// 401-burst auto-disable callback (D-06): update settings + clear lockfile tunnelUrl
367+
// 401-burst auto-disable callback (D-06): update settings + clear lockfile tunnelUrl.
368+
// The SERVER now stops the tunnel before invoking this — it owns the
369+
// single handle, so it reaches dashboard-enabled tunnels too, which
370+
// this callback never could. The stop below is a defensive no-op
371+
// (tunnel.js stop() returns early once `stopping` is set); it stays so
372+
// the mirror is cleared and an un-adopted handle is still torn down.
368373
if (activeTunnel) {
369374
await activeTunnel.stop().catch(() => undefined);
370375
activeTunnel = null;
@@ -453,6 +458,23 @@ export async function startDaemon(options = {}) {
453458
}
454459
},
455460
});
461+
462+
// Hand ownership to the server. Until this existed, a boot-started
463+
// tunnel lived ONLY in this closure, so /daemon/disable-tunnel saw
464+
// null, skipped stop(), and still returned ok + nulled the lockfile —
465+
// the public URL kept serving while every UI surface said "off".
466+
server.adoptTunnel?.(activeTunnel);
467+
468+
// waitUntilReady is created eagerly in tunnel.js and rejected when
469+
// cloudflared exits before publishing a URL (offline boot, blocked
470+
// egress, trycloudflare 429). Nothing consumed it, and there is no
471+
// process-level unhandledRejection handler, so Node's default throw
472+
// killed the daemon ~1s after it began serving.
473+
activeTunnel?.waitUntilReady?.catch?.((err) => {
474+
console.error(
475+
`[airtable-mcp] tunnel never became ready: ${err instanceof Error ? err.message : String(err)}`,
476+
);
477+
});
456478
} catch (err) {
457479
// Non-fatal: daemon continues without tunnel (D-04: no auto-restart)
458480
console.error(`[airtable-mcp] Tunnel auto-start failed: ${err instanceof Error ? err.message : String(err)}`);

packages/mcp-server/src/daemon/server.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,18 @@ export async function startDaemonServer(options = {}) {
339339
const ip = req.headers?.['cf-connecting-ip']
340340
?? req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
341341
?? null;
342+
// Stop the tunnel HERE, on the one handle this module owns. The launcher's
343+
// onTunnelAutoDisable callback used to be the only thing that stopped it,
344+
// and it could only see a tunnel the LAUNCHER had started at boot — so a
345+
// tunnel enabled from the dashboard (which fills this closure instead)
346+
// survived the tripwire entirely: `tunnelAutoDisabled` latched, the UI said
347+
// "Auto-disabled", and cloudflared kept serving the public hostname.
348+
// The callback is now bookkeeping only (settings + lockfile).
349+
if (activeTunnel) {
350+
const stopping = activeTunnel;
351+
activeTunnel = null;
352+
void stopping.stop().catch(() => undefined);
353+
}
342354
publishEvent('daemon:tunnel-auto-disabled', { failures: authFailureCount, windowMs: BURST_WINDOW_MS, ip });
343355
options.onTunnelAutoDisable?.({ failures: authFailureCount, windowMs: BURST_WINDOW_MS, ip });
344356
}
@@ -886,5 +898,24 @@ export async function startDaemonServer(options = {}) {
886898
stop,
887899
publishEvent,
888900
getHealth,
901+
/**
902+
* Hand a tunnel started elsewhere (the launcher's boot auto-start) to this
903+
* module, so there is exactly ONE owner of the running tunnel.
904+
*
905+
* Without this the launcher kept its own `activeTunnel` that this closure
906+
* could not see, and the three paths that stop a tunnel each reached only
907+
* half the cases: `/daemon/disable-tunnel` was a silent no-op on a
908+
* boot-started tunnel (it reported ok and nulled the lockfile while
909+
* cloudflared kept serving), `/daemon/enable-tunnel`'s stop-the-existing
910+
* guard missed it and orphaned a second cloudflared, and the 401-burst
911+
* tripwire could only stop boot-started ones.
912+
*/
913+
adoptTunnel(handle) {
914+
activeTunnel = handle;
915+
},
916+
/** The tunnel handle this module currently owns (read-only; null when none). */
917+
getActiveTunnel() {
918+
return activeTunnel;
919+
},
889920
};
890921
}

0 commit comments

Comments
 (0)