Skip to content

Commit 052cefc

Browse files
committed
fix(security): custom-profile category expansion, record-prune table-id mismatch, exit-intent ownership, adm-zip 0.5.x zip-bomb, Ajv URI parser
Six findings from the PR #22 acceptance audit. Each is reproduced before fixing and pinned by a regression test. extension tool-profile: startup and file-to-settings sync now preserve the server's fail-closed semantics for post-legacy categories. An older custom profile with no explicit keys for a later-added category's tools was silently widened to include that category when VS Code's package defaults rewrote the file first. Now
1 parent dde4596 commit 052cefc

20 files changed

Lines changed: 633 additions & 92 deletions

CHANGELOG.md

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

77
## [Unreleased]
88

9+
### Fixed — PR #22 acceptance audit follow-ups (2026-08-01)
10+
11+
- **Existing custom profiles stay closed when a category is added.** The MCP server already
12+
treated absent tools in post-legacy categories as disabled, but extension startup rewrote the
13+
same file from VS Code's package defaults first — silently enabling `local-write`. Startup and
14+
file-to-settings sync now preserve the server's fail-closed semantics, including mixed per-tool
15+
states, and a later category toggle clears those imported overrides before applying the user's
16+
explicit choice.
17+
- **Record-prune failure accounting now follows destination table IDs.** Per-table write results
18+
were stored under the source table name but read back under the destination table name, so a
19+
renamed matched table could lose the all-writes-failed no-prune guard. A regression test runs the
20+
write and prune phases together with differing names.
21+
- **Daemon exit intents have request ownership.** An older concurrent `/mcp` response could finish
22+
after `manage_daemon stop` staged its process-wide intent, steal it, and begin shutdown before the
23+
stop confirmation flushed. `AsyncLocalStorage` now binds the intent to the response that staged
24+
it, with a deterministic two-request regression test.
25+
- **Session restore no longer bundles vulnerable `adm-zip` 0.5.x.** A tiny crafted archive could
26+
declare a multi-gigabyte uncompressed entry and crash the extension host before CRC validation.
27+
The bundled parser is now 0.6.0, the restore picker size-checks before reading only the encryption
28+
header, and declared uncompressed totals are rejected before entry allocation.
29+
- **The shipped Ajv URI parser is patched without widening the dependency refresh.** The lockfile
30+
moves `fast-uri` 3.1.0 → 3.1.5 through the existing MCP SDK dependency, clearing its four
31+
production audit findings while leaving the SDK and browser-auth stack pinned.
32+
- Corrected the live tool references to list 9 read tools and 2 local-file-write tools, completed
33+
the dashboard's early-activation category fallback, and removed stale secret-bearing login CLI
34+
examples and an inapplicable `login.json` hint.
35+
936
### Fixed — formula diagnostics were quadratic, blocking the editor on every keystroke (2026-07-31)
1037

1138
- **`isInsideExclusionRange` was a linear scan run once per character.** `ranges.some(...)` is

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ Located at `packages/mcp-server/dev-tools/` (gitignored):
268268
All under `airtableFormula.*`:
269269
- `mcp.autoConfigureOnInstall` — auto-write MCP config to detected IDEs on first launch
270270
- `mcp.toolProfile``read-only` (10 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom`
271-
- `mcp.categories.{read,recordRead,recordWrite,recordDestructive,tableWrite,tableDestructive,fieldWrite,fieldDestructive,viewWrite,viewDestructive,viewSection,viewSectionDestructive,formWrite,extension,sync,daemon,localWrite}` — per-category toggles when profile is `custom`. `sync`, `recordDestructive` and `daemon` default to **off**; the other 13 default to on.
271+
- `mcp.categories.{read,recordRead,recordWrite,recordDestructive,tableWrite,tableDestructive,fieldWrite,fieldDestructive,viewWrite,viewDestructive,viewSection,viewSectionDestructive,formWrite,extension,sync,daemon,localWrite}` — per-category toggles when profile is `custom`. `sync`, `recordDestructive` and `daemon` default to **off**; the other 14 default to on.
272272
- `mcp.daemonPort` — fixed TCP port for the shared MCP daemon HTTP server (default 8723, kept stable across restarts; 0 = automatic/ephemeral; falls back to an automatic port if the chosen port is busy; takes effect on next daemon restart)
273273
- `mcp.authMode``browser` (default; headless Chrome mints the cookie, calls go direct-HTTP) / `byo` (cookie-only, no browser; cookie from `~/.airtable-user-mcp/credentials.json` or `AIRTABLE_COOKIE`) / `direct-login` (browser-free email+password+TOTP; `login.json` or `AIRTABLE_EMAIL/PASSWORD/TOTP_SECRET`). Injected as `AIRTABLE_AUTH_MODE` into the spawned server via `buildDaemonEnv`/`registration.ts` (non-default only). Takes effect on next daemon/server restart.
274274
- `mcp.httpClient``fetch` (default) / `impit` (Chrome-TLS impersonation fallback). Injected as `AIRTABLE_HTTP_CLIENT`. `impit` must be available to the server (optionalDependency; add to the vendored deps for the bundled path).

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,8 @@ Manage Airtable bases with capabilities **not available through the official RES
198198

199199
| Category | Tools | Highlights |
200200
|:---------|:-----:|:-----------|
201-
| **Schema Read** | 11 | Full schema inspection — bases, tables, fields, views, sidebar sections, record templates; download all formula fields to local files |
201+
| **Schema Read** | 9 | Full schema inspection — bases, tables, fields, views, sidebar sections, record templates |
202+
| **Local File Write** | 2 | Download one formula field or every formula in a base to caller-chosen `.formula` files; excluded from `read-only` |
202203
| **Record Read** | 1 | `query_records` — up to 1 000 records/call with resolved field values; `search` param works on lookup/rollup fields (REST API `filterByFormula` doesn't) |
203204
| **Record Write** | 4 | `create_records` / `update_records` / `duplicate_records` / `upload_attachment` (the only way to write `multipleAttachments` cells by URL) |
204205
| **Record Destructive** | 1 | `delete_records` — batch-delete records from a table |

packages/extension/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@
541541
"airtableFormula.mcp.categories.read": {
542542
"type": "boolean",
543543
"default": true,
544-
"description": "Read / Inspect tools: get_base_schema, list_tables, get_table_schema, list_fields, list_views, get_view, validate_formula, list_view_sections."
544+
"description": "Read / Inspect tools: get_base_schema, list_tables, get_table_schema, list_fields, list_views, get_view, validate_formula, list_view_sections, list_record_templates."
545545
},
546546
"airtableFormula.mcp.categories.tableWrite": {
547547
"type": "boolean",
@@ -760,7 +760,7 @@
760760
"@types/vscode": "^1.100.0",
761761
"@vscode/test-electron": "^2.4.0",
762762
"@vscode/vsce": "^3.0.0",
763-
"adm-zip": "^0.5.17",
763+
"adm-zip": "^0.6.0",
764764
"airtable-user-mcp": "workspace:*",
765765
"archiver": "^7.0.1",
766766
"tsup": "^8.0.0",

packages/extension/src/mcp/session-backup.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ const CONFIG_DIR = path.join(os.homedir(), '.airtable-user-mcp');
1919
const MAX_BACKUP_FILE_BYTES = 200 * 1024 * 1024; // 200 MB on-disk
2020
const MAX_UNZIPPED_BYTES = 500 * 1024 * 1024; // 500 MB post-extraction
2121

22+
async function assertBackupFileSize(srcPath: string): Promise<void> {
23+
const stat = await fs.stat(srcPath);
24+
if (stat.size > MAX_BACKUP_FILE_BYTES) {
25+
throw new Error(`Backup file is too large (${stat.size} bytes, max ${MAX_BACKUP_FILE_BYTES}).`);
26+
}
27+
}
28+
2229
export async function backupSession(destPath: string, password?: string): Promise<void> {
2330
const zipBuffer = await createZipBuffer(CONFIG_DIR);
2431

@@ -53,10 +60,7 @@ function parseEncryptedHeader(data: Buffer): BackupHeader {
5360
export async function restoreSession(srcPath: string, password?: string): Promise<void> {
5461
// H1 — size guard. stat before reading so a 4 GB attacker file doesn't OOM
5562
// the extension host.
56-
const stat = await fs.stat(srcPath);
57-
if (stat.size > MAX_BACKUP_FILE_BYTES) {
58-
throw new Error(`Backup file is too large (${stat.size} bytes, max ${MAX_BACKUP_FILE_BYTES}).`);
59-
}
63+
await assertBackupFileSize(srcPath);
6064

6165
const fileData = await fs.readFile(srcPath);
6266

@@ -106,7 +110,14 @@ export async function restoreSession(srcPath: string, password?: string): Promis
106110
continue;
107111
}
108112

109-
// Track uncompressed size to guard against zip-bombs.
113+
// Reject from the central-directory size before getData() allocates the
114+
// decompressed buffer. adm-zip validates/caps the actual output; the
115+
// post-read check below remains defense in depth for malformed metadata.
116+
const declaredSize = entry.header.size;
117+
if (!Number.isSafeInteger(declaredSize) || declaredSize < 0 || declaredSize > MAX_UNZIPPED_BYTES - totalSize) {
118+
throw new Error(`Backup rejected: uncompressed size exceeds ${MAX_UNZIPPED_BYTES} bytes (possible zip-bomb).`);
119+
}
120+
110121
const data = entry.getData();
111122
totalSize += data.length;
112123
if (totalSize > MAX_UNZIPPED_BYTES) {
@@ -155,6 +166,20 @@ export function isEncryptedFile(data: Buffer): boolean {
155166
return isEncrypted(data);
156167
}
157168

169+
/** Check a selected backup without reading the entire file into memory. */
170+
export async function isEncryptedBackupFile(srcPath: string): Promise<boolean> {
171+
await assertBackupFileSize(srcPath);
172+
173+
const handle = await fs.open(srcPath, 'r');
174+
try {
175+
const header = Buffer.alloc(MAGIC.length);
176+
const { bytesRead } = await handle.read(header, 0, header.length, 0);
177+
return isEncrypted(header.subarray(0, bytesRead));
178+
} finally {
179+
await handle.close();
180+
}
181+
}
182+
158183
function isEncrypted(data: Buffer): boolean {
159184
return data.length >= 4 && data.subarray(0, 4).equals(MAGIC);
160185
}

0 commit comments

Comments
 (0)