Skip to content

fix(security): deep audit remediation — 5 high + 6 medium findings - #22

Open
ARHAEEM wants to merge 6 commits into
mainfrom
fix/security-deps-and-audit
Open

fix(security): deep audit remediation — 5 high + 6 medium findings#22
ARHAEEM wants to merge 6 commits into
mainfrom
fix/security-deps-and-audit

Conversation

@ARHAEEM

@ARHAEEM ARHAEEM commented Jul 31, 2026

Copy link
Copy Markdown
Member

Remediates the findings from a multi-agent security/performance/leak/conflict audit of the monorepo (51 raw findings → 29 survived adversarial verification; 13 refuted, 3 contested).

Every fix here was reproduced before being written and is pinned by a regression test that fails against the old code.

HIGH

Finding Where
Split tunnel ownership — disable-tunnel and the 401-burst tripwire each stopped the wrong handle while the UI, lockfile and /daemon/health all reported "off" daemon/server.js, daemon/launcher.js
download_* annotated readOnlyHint: true while writing files at a caller-supplied path — the signal MCP clients use to auto-approve without prompting index.js
Seven row-template methods interpolated an unvalidated templateId into the API URL path client.js
unconfigureMcpToml/unconfigureHelix truncated the user's config from our marker to EOF auto-config/lsp-config.ts
The default v2 formatter silently deleted lowercase function names and wrote the result to disk vendor/formula-*-v2.js

Measured before the fixlower({Email})({Email}), If({A},1,0)I({A},1,0). The mangled output still parses, so the try/catch never fired: plain editor.formatOnSave corrupted buffers silently, and the bulk path writes unopened files with no undo stack.

Measured against the old lsp-config.ts — a config.toml holding one extra MCP server was reduced to a single newline.

MEDIUM

  • pruneRecords deleted dest rows in a table whose every write failed (the gate was run-wide, so one converged row elsewhere disarmed it)
  • pruneSchema deleted the fieldMappings targets apply() had just validated — dest-only by construction, hence always orphans
  • The drift guard fingerprints only the destination, so a stale plan's orphan list deleted fields the source had since gained
  • stopDaemon --force SIGKILLed a lockfile pid with no identity check (recycled pid → kills a stranger; on Windows SIGTERM = TerminateProcess)
  • buildUpdateCells never diffed against the destination — 20,000 sequential POSTs for a 1000×20 no-op re-sync
  • Formula diagnostics were quadratic: 83.3 ms → 9.6 ms on the repo's largest shipped example, 592 ms → 10 ms on an 87 KB synthetic, output identical

Two more mediums fell out for free alongside the above: the v1 tokenizer infinite loop (unrecognized char → V8 OOM, killed the extension host) and the boot tunnel's unconsumed waitUntilReady rejection.

Dependencies

undici ^7.0.0^7.28.0, closing a TLS certificate-validation bypass on the exact EnvHttpProxyAgent path proxy.js uses. It is not in the esbuild external list, so the vulnerable copy was compiled into dist/mcp/index.mjs and frozen into all 8 platform VSIXes — extension users could not have fixed it themselves. Reaching them needs a rebuild + republish.

Diff is deliberately undici-only: bumping the hono advisories pulled 24 unrelated upgrades including patchright 1.59.4 → 1.61.1 (the Chromium driver behind the default auth path), and those advisories are unreachable anyway — their vulnerable code is serve-static, CORS middleware and JSX, while the SDK only uses @hono/node-server's request/response conversion.

Verification

  • 1451 mcp-server · 162 language-services · 88 webview tests pass
  • check:tool-sync green · pnpm build succeeds
  • All 16 pinned platform packages still match their lockfile-verified tarballs

⚠️ One extension test (session-backup → "rejects backups larger than 200 MB") currently fails with ENOSPC. The build machine's disk is 100% full and that test truncates a 201 MB file. It passed earlier in the same session, and neither it nor packages/extension is touched by the final commit — but it should be re-run on a machine with free space before merge.

Deliberately not changed

  • outputPath/outputDir are still honoured as given — they are the user's explicit choice, and confining them to cwd would break legitimate "save to my Desktop" use. The consent step is restored by the annotation fix instead.
  • download_* remain in the read category. The annotation is fixed so clients prompt, but a file-writing tool still sits in the profile advertised as "reading only" — recategorising changes the published profile counts (12/54/72) and is a product decision.

🤖 Generated with Claude Code

ARHAEEM and others added 4 commits July 31, 2026 20:04
undici 7.24.5 was in range for a TLS certificate validation bypass via
dropped requestTls. This is not a theoretical transitive dependency:
src/proxy.js constructs undici's EnvHttpProxyAgent for every proxied
environment, which is the exact affected code path — a corporate-proxy
user could have had certificate validation silently skipped.

It also shipped further than npm. undici is not in the esbuild `external`
list in scripts/bundle-mcp.mjs, so the vulnerable copy was compiled into
dist/mcp/index.mjs and frozen into all 8 platform VSIXes; extension users
could not have fixed it by updating anything. Reaching them needs a
rebuild + republish.

Raise the floor ^7.0.0 -> ^7.28.0 (resolves 7.29.0). Diff is deliberately
undici-only: bumping the hono/@hono/node-server advisories that
@modelcontextprotocol/sdk carries pulled 24 unrelated upgrades, including
patchright 1.59.4 -> 1.61.1 (the Chromium driver behind the default auth
path), and those advisories are unreachable anyway — their vulnerable code
is serve-static, CORS middleware and JSX, while the SDK only uses
@hono/node-server's request/response conversion.

Verified: 1438 mcp-server + 395 extension + 88 webview tests pass,
check:tool-sync green, and all 16 pinned platform packages still match
their lockfile-verified tarballs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects found by audit, each reproduced before being fixed.

client.js: all seven row-template methods interpolated a caller-supplied
templateId into the API URL path with no assertAirtableId, while the file
defines AIRTABLE_ID_RE with the comment "Prevents path traversal" and
applies it at 128 other call sites. The value arrives straight from tool
args and auth._rawApiCall passes an absolute URL through unchanged, so a
crafted id reached arbitrary internal endpoints under the live session
cookie. applyRowTemplate's body is byte-identical to what deleteTable
posts to table/{id}/destroy, so a table-write-only caller could destroy a
table while skipping deleteTable's expectedName confirmation.
createRowTemplate was never affected -- it generates its own id.

vendor/formula-{beautifier,minifier}-v2.js: the default formatter deleted
lowercase function names and wrote the result to disk. Airtable function
names are case-insensitive but both tokenizers matched /[A-Z_]/ with no /i
flag, so a-z fell through to the operator catch-all and vanished.
Measured: lower({Email}) -> ({Email}), If({A},1,0) -> I({A},1,0). The
mangled output still parses, so the try/catch never fired -- format-on-save
corrupted buffers silently and the bulk path wrote unopened files with no
undo stack. Now matches case-insensitively and canonicalises only for the
FUNCTIONS/CONSTANTS lookup, preserving the user's casing.

vendor/formula-{beautifier,minifier}.js: the v1 identifier fallback was the
only tokenizer branch with no unconditional advance, so an unrecognized
character (';', '%', '[', a pasted smart quote) spun the loop allocating
until V8 aborted, killing the extension host. Now throws, engaging the
callers' existing try/catch so the formula is returned unchanged.

Verified: 1438 mcp-server + 416 extension (incl. 21 new regression cases)
+ 88 webview tests pass, check:tool-sync green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
…adratic diagnostics

Six medium findings from the audit. Each is pinned by a regression test.

sync/records.js: pruneRecords' only "don't prune after a failure" gate was
RUN-wide, so one converged row in any other table (counted as skipped)
disarmed it. A FIELD_FORBIDDEN 403 is a per-field permission property that
fails every row of exactly one table by design, so a wholly-failed table is
routine -- and under mirror+confirmDeletions its pre-existing dest rows were
deleted while the replacement data was never written, reporting phase=done.
Pass 1 now records per-table outcomes and prune skips a table that wrote
nothing and failed, emitting RECORDS_FAILED_PRUNE_SKIPPED.

sync/prune-schema.js: pruneSchema deleted the fieldMappings targets apply()
had just validated. A target must exist on the dest but not on the source --
the documented injection pattern (source autoNumber Code -> dest text field
InjectID) is dest-only by construction and so always an orphan. The records
job then re-validated, threw FIELD_MAP_INVALID and synced zero records: a
destination column destroyed and no data written.

sync/index.js: the drift guard fingerprints only the DESTINATION, but
plan.orphans is a statement about the SOURCE. A source-side addition between
plan and apply was invisible -- dest untouched so the fingerprint matched, no
DRIFT, no create action in the stale plan -- and pruneSchema then deleted the
dest field that now legitimately matched. expectedName cannot help; the field
kept its name. apply() now re-reads the source before any schema deletion and
drops orphans that regained a counterpart; if that read fails it skips all
schema deletion rather than trusting a stale list.

daemon/launcher.js: stopDaemon --force SIGKILLed a lockfile pid with no
identity check. isStale()'s bare process.kill(pid,0) cannot tell our daemon
from a process that recycled the pid, and on Windows SIGTERM maps to
TerminateProcess -- an unsavable hard kill of a stranger. Now probes
/daemon/health and requires the uuid to echo the lockfile's, mirroring the
extension's _verifyDaemonIdentity. Unproven means release the lock and leave
the process alone.

sync/records.js: buildUpdateCells never compared against the destination, so
every mapped scalar cell of every mapped row was re-posted on each re-sync --
updateRecords issues one serialized POST per CELL, i.e. 20,000 sequential
requests for a 1000-row x 20-field no-op run. Converged cells are now skipped.

language-services diagnostics.ts: isInsideExclusionRange was a linear scan run
once per character by three checkers, so cost was chars x field-refs with no
debounce and no size cap -- and in --tcp daemon mode that blocking is shared
by every attached editor. Now a binary search over the already-ascending
ranges. Measured on the repo's largest shipped example (38,830 chars / 741
refs): 83.3ms -> 9.6ms. On an 87KB/4000-ref synthetic: 592ms -> 10ms.
Diagnostic output identical.

Verified: 1451 mcp-server + 162 language-services + 88 webview tests pass,
check:tool-sync green, pnpm build succeeds. One extension test
(session-backup "rejects backups larger than 200 MB") fails with ENOSPC --
the disk is 100% full and that test truncates a 201 MB file. It passed
earlier this session and neither it nor packages/extension is touched by
this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

ARHAEEM and others added 2 commits August 1, 2026 00:07
download_formula_field and download_base_formulas READ from Airtable but
WRITE .formula files to a caller-chosen path, so they did not belong in the
`read` category -- the read-only profile is advertised as "Schema inspection,
formula validation, and record reading only". Correcting the readOnlyHint
annotation (previous commit) restored the MCP client's consent prompt, but the
profile itself still claimed a file-writing tool was read-only.

New `local-write` category, included in safe-write and full, so those tool
sets are unchanged (54 / 72). Only read-only changes: 12 -> 10.

Deliberately NOT added to LEGACY_CATEGORIES_DEFAULT_ON, per the invariant
documented there -- a pre-existing on-disk `custom` profile must not silently
widen to include a category it never opted into.

Propagated through every mirror the sync guard checks: tool-config.js,
tool-profile.ts, shared types, extension package.json (new
mcp.categories.localWrite setting + enumDescriptions), webview store +
Settings toggle + store test, CLAUDE.md, all three READMEs, skill templates
and the three banner SVGs.

Verified: check:tool-sync green at 10/54/72 with 17 labels, 1451 mcp-server
+ 421 extension + 88 webview tests pass, pnpm build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelve of the sixteen low findings. Two were already fixed in earlier commits
(the '..' output-dir escape, folded into confineToDir), and two are left
open deliberately (see below).

Credentials and permissions:
- logout claimed "Browser session cleared" and exited 0 while
  credentials.json / login.json still held a live session, so the next start
  logged straight back in. Now reports them and exits non-zero.
- login no longer accepts --password / --otp-secret. argv is world-readable
  via /proc/<pid>/cmdline for the ~5 minute poll and a captured base32 TOTP
  seed is a permanent 2FA bypass. Env vars or login.json instead.
- config dir 0700; session-backup archive 0600 (it holds the cookie jar,
  daemon.token and daemon.lock); IDE config writes carrying a PAT 0600 --
  the atomic rename replaces the inode, resetting any mode the user set.

Correctness:
- sync_base validates planId/diffId. They are interpolated into on-disk
  filenames and join() normalises '..', so diffId "x/../../tools-config"
  overwrote tools-config.json -- which load() merges over a defaultConfig()
  whose activeProfile is 'full'.
- an unverifiable view no longer reads as clean. snapshotViewFiltered is the
  only signal suppressing pruneRecords and was set only when every view's
  filter state was KNOWN, so a getView that threw made a filtered row set
  look complete and mirror could delete real records as false orphans.
- attachment fetch checks r.ok. A 403/404 body was uploaded as the file,
  counted as success, and written into the persisted dedupe map, so a re-run
  skipped the corrupted cell instead of repairing it.
- an unreadable daemon.lock is no longer deleted with no liveness check --
  acquire() publishes it empty and fills it a moment later, so a racer could
  evict a lock a live daemon was mid-write.
- apply.lock expires instead of wedging forever on a recycled pid, and
  APPLY_LOCKED now names the lock path.
- the exit-intent slot only consumes an intent staged during this request.

Performance:
- offsetToPosition memoises line starts and binary-searches instead of
  rescanning from 0 per diagnostic: ')'.repeat(20000) ~1000ms -> 28ms,
  'IF('.repeat(10000) ~3500ms -> 484ms.
- showToolStatus reuses one OutputChannel.

Left open deliberately: createRecords re-resolving the table schema per
chunk, and reapplyViewFilters prefetching view configs the next loop
discards. Both are bounded micro-optimisations the verifier measured as
saving ~1 request per 50 creates and "nearly nothing" respectively; neither
is worth the regression risk in this pass.

Verified: 1451 mcp-server + 421 extension + 162 language-services + 88
webview tests pass, check:tool-sync green at 10/54/72, pnpm build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +1773 to +1775
const tableCounts = result.perTable?.[t.name];
if (tableCounts && tableCounts.failed > 0
&& tableCounts.created === 0 && tableCounts.updated === 0 && tableCounts.skipped === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The per-table failure check in pruneRecords uses the destination table name for a lookup, but the statistics are keyed by the source table name, bypassing the check when names differ.
Severity: HIGH

Suggested Fix

The result.perTable statistics dictionary should be keyed by a consistent identifier in both the write and read locations. Using the destination table ID would be a robust solution, as tables are matched by ID throughout the sync process. This ensures the lookup succeeds even when table names differ.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/mcp-server/src/sync/records.js#L1773-L1775

Potential issue: A safety gate in `pruneRecords` intended to prevent data deletion is
silently bypassed when source and destination tables have different names. The sync
process writes per-table operation counts to `result.perTable` using the source table's
name as the key. However, the check in `pruneRecords` reads from this map using the
destination table's name. Since the sync system matches tables by ID, differing names
are a supported scenario. When names differ, the lookup fails, and the safety check is
skipped. This could lead to the deletion of all records in a destination table if all
write operations for that table failed during the sync.

Also affects:

  • packages/mcp-server/src/sync/records.js:759~759

Did we get this right? 👍 / 👎 to inform future reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant