Skip to content

fix(plugins): reject path traversal in manifest identity before the install rm - #548

Merged
Weegy merged 1 commit into
mainfrom
fix/plugin-install-path-traversal
Jul 30, 2026
Merged

fix(plugins): reject path traversal in manifest identity before the install rm#548
Weegy merged 1 commit into
mainfrom
fix/plugin-install-path-traversal

Conversation

@Weegy

@Weegy Weegy commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes a path traversal in the plugin install pipeline that is exploitable today, independent of any epic. Found while planning #470; landing it standalone so it does not wait on unrelated decisions.

The chain

  1. manifestLoader.ts reads identity.id / identity.version from the uploaded manifest with no charset validation — the only check is if (!id || !name || !version) return null.
  2. packageUploadService.ts builds finalDir = path.join(packagesDir, plugin.id, plugin.version).
  3. It then runs fs.rm(finalDir, { recursive: true, force: true }) followed by fs.rename(packageRoot, finalDir).

Verified on this machine:

path.join('/app/.uploaded-packages', '..', 'migrations')  ===  '/app/migrations'

So a manifest declaring identity.id: '..' and identity.version: 'migrations' deletes the real migrations directory and replaces it with attacker-controlled package content. At next boot the migrator does a readdir of exactly that path and executes every .sql it does not already have in its ledger.

Not operator-only. The same ingest() path is reached from routes/registryInstall.ts (ZIPs fetched from a remote plugin registry), profileBundleImporter.ts (plugin ZIPs vendored inside an imported profile bundle), dependencyChainResolver.ts and builder/installCommit.ts.

Today .sql is not in the ZIP extension allowlist, which limits this to arbitrary directory delete/replace rather than SQL execution. That allowlist entry was proposed as part of #470 — this fix is a hard prerequisite for it, and the reason it was pulled from that batch.

The fix — two independent layers

Layer 1, manifestLoader.ts: strict patterns for identity.id (npm-style, optionally scoped) and identity.version. Reject, never sanitise.

Layer 2, packageUploadService.ts: re-assert containment before the destructive fs.rmpath.resolve(finalDir) must stay under path.resolve(packagesDir). Defence in depth: the service layer does not rely on the loader having stayed correct. Also guards a small set of reserved root entries (notably the node_modules symlink the store plants, where a rename would land in the host's real node_modules).

Verification

An independent adversarial review pass reproduced every claim on a clean checkout and found no critical, high or medium issues. It probed the patterns directly — .., ., ...., ..a, a/../.., /etc/passwd, a/b, @../a, @a/.., %2e%2e, backslash forms, NUL bytes, spaces, uppercase, leading -/_ all rejected — and confirmed that values which pass and look suspicious (a.., 1.0.0-.., @a../b) still resolve to a single contained path segment.

It also checked a symlink bypass is unreachable: zipExtractor rejects symlink entries outright, and every intermediate path segment is created by fs.mkdir, never from archive content.

Compatibility: all 19 in-repo manifest.yaml identities and all 8 in omadia-byte5-plugins pass both patterns. AgentIdSchema / SemverSchema in the Builder are strict subsets, so no Builder output is rejected.

Test plan

  • 46 new tests in middleware/test/pluginInstallPathTraversal.test.ts, stable across 3 isolated runs
  • Traversal attempts rejected: .., a/../.., absolute paths, URL-encoded, backslash separators, NUL
  • Legitimate scoped names (@omadia/plugin-office) still install
  • The destructive fs.rm is proven not reached for a rejected package
  • Full middleware suite: 5,193 pass. One intermittent single failure is the repo's documented load-sensitive flakiness — reproducible with a file of 48 trivial assertions, and absent from the baseline
  • Typecheck + lint clean

Known follow-ups (low, not blocking)

  • identity.version has no length cap, so a semver-legal 600-char version reaches fs.rm and throws ENAMETOOLONG as a 500 rather than the 422 every other malformed-manifest path returns.
  • A manifest rejected by the new gate is reported to the uploader as package.manifest_invalid ("schema_version"), which misdiagnoses the cause; the charset is also undocumented in docs/creating-plugins.md.
  • The gate runs at catalog-load time too, so an already-installed plugin with a non-conforming identity would disappear on restart while its registry entry stays active. No in-repo or byte5 plugin is affected.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…nstall rm

`identity.id` and `identity.version` from an uploaded manifest are used as
path segments (`<packagesDir>/<id>/<version>`) and that directory is
`fs.rm -rf`'d before the staged package is renamed onto it. Neither field
was charset-validated, so a manifest with id `..` and version `migrations`
resolved to a sibling of the packages root, deleted it, and replaced it with
attacker-controlled content. Reachable without operator interaction via the
remote registry install and via vendored plugins in an imported profile
bundle.

Two independent layers:
  - manifestLoader rejects (not sanitises) an id outside the lowercase
    npm-name charset and a version that is not semver — a hard `null`,
    which the upload path surfaces as `package.manifest_invalid`;
  - packageUploadService re-proves containment immediately before the
    destructive `fs.rm`, and additionally refuses ids that collide with the
    reserved packages-root entries (`node_modules` — the host symlink —
    and `index.json`).
@Weegy
Weegy merged commit 09ff9cd into main Jul 30, 2026
8 checks passed
Weegy added a commit that referenced this pull request Jul 30, 2026
…ncy-safe (#552)

* feat(plugins): allow .sql in distributed plugin packages

A distributed plugin ships its own schema as `migrations/*.sql`, but the
zip extractor rejected the extension, so the package failed ingest with
`zip.forbidden_extension`.

Adding `.sql` was previously refused because it would have escalated the
manifest-identity path traversal (an id of `..` with version `migrations`
resolved onto the real migrations directory) from a directory delete into
arbitrary SQL executed at boot. That traversal was closed in 09ff9cd, and
the remaining routes from uploaded content to a migrator-scanned path were
re-verified before this change:

  - all eight SQL migrators resolve their directory from their own
    `import.meta.url`, the fixed `middleware/migrations`, or an operator
    env override — never from package content;
  - the three extraction call sites land only under state dirs
    (uploaded-packages staging/final, builder previews), never in the
    shipped code tree;
  - the `node_modules` symlink at the packages root is charset-valid as an
    id and is blocked by the reserved-root check, not by the charset gate.

Tests pin those conditions rather than just the happy path: `.sql` accepted
by the default allowlist, still rejected under an explicit override that
omits it, and no `.sql` surviving outside the packages root for a traversing
identity, the reserved `node_modules` id, or a Zip-Slip entry name.

Shared package-zip fixtures move to `test/_helpers/pluginPackageZip.ts` so
both install-pipeline suites use one builder.

* fix(migrations): make all 8 SQL migrators safe against concurrent multi-replica boot

Every migrator was read-ledger -> filter -> apply with no mutual exclusion, so
two replicas booting together both executed the same pending list. `CREATE ...
IF NOT EXISTS` masks that; `ALTER TABLE ... ADD CONSTRAINT` does not — the loser
gets 42710 and its boot fails. `conductorWebhookEndpointStore.pg.test.ts` already
carried a test-level `migrateWithRetry` workaround for the same race.

Design (identical in all eight, no shared helper — that is separate work):

- Read the ledger BEFORE locking and return early when nothing is pending, so
  the steady-state boot takes no lock and can never queue behind a migrating
  replica.
- Acquire with `pg_try_advisory_lock(4410, hashtext(<ledger table>))` in a
  bounded 2s poll, never the blocking `pg_advisory_lock`: three of the eight run
  inside a plugin `activate()` that ToolPluginRuntime hard-caps at 10s, and the
  knowledge-graph plugin can already have spent 6s in `waitForPostgres` first.
  The try-variant also returns a boolean the code actually reads, so the session
  always knows whether it holds the lock.
- Re-read the ledger UNDER the lock, so a replica that queued behind a winner
  never re-applies what the winner just applied.
- A loser that never gets the lock re-reads the ledger: if the winner finished,
  it continues; otherwise it throws a retryable error naming the pending files.
  The message says "timed out" so `bootstrap.retryErroredPlugins` classifies it
  as transient instead of latching the plugin `errored`.
- `pg_advisory_unlock` runs on the success path only, inside `try`, and its
  boolean is read. It is never awaited in `finally`, where it could hang on a
  half-open connection (these pools set no statement_timeout) or replace the
  original migration error. Any path that cannot prove the lock was released
  ends at `client.release(true)`, which destroys the connection and releases the
  session lock with it. `client.release()` always runs.
- `ensureLedger` retries once on 42P07/23505: `CREATE TABLE IF NOT EXISTS` is
  not atomic against a concurrent `CREATE TABLE` of the same name.

Tests cover, for all eight: no lock when there is no work, lock taken before the
first migration, the under-lock re-read, the bounded loser error, the loser
whose winner finished, an original error preserved through a failing migration,
a throwing unlock, an unlock that reports not-held, a driver that models no
advisory locks, the ledger DDL race, and that 6s + 2s fits the 10s activate cap.

* feat(plugins): allow .sql in packages + make the 8 migrators concurrency-safe

Two of three wave-2 packets shipped after review; the third is held back
again.

.SQL IN THE ZIP ALLOWLIST — unblocked by #548.
It was rejected in the first batch because it would have escalated the
then-live path traversal from directory delete/replace into arbitrary
SQL execution. #548 closed that, and the reviewer verified independently
that no route remains from uploaded content to any migrator-scanned
path: all 8 migrators derive their directory from import.meta.url or an
operator env var, never from package content, and the 3 extraction
destinations are staging/preview paths with generated names.

The stronger argument, which the analysis had buried: .js/.mjs/.cjs are
already allowlisted and dynamic-import()ed by the runtime, so anyone who
can get a zip to ingest() already holds in-process code execution. .sql
is strictly weaker than what the trust boundary already grants — which
makes it security-inert independently of the migrator analysis.

Also adds `migrations` to both boilerplate build-zip.mjs INCLUDE lists.
Without it the directory is silently dropped at packaging time: the copy
loop skips anything not on the list, so an author following the
boilerplate gets a green install and no schema. The allowlist entry
alone was necessary but not sufficient.

MIGRATOR CONCURRENCY — shipped on the third attempt.
Every migrator was read-ledger -> filter -> apply with no mutual
exclusion, so two replicas booting together both execute; IF NOT EXISTS
masks it, ADD CONSTRAINT does not (42710).

Two earlier designs were rejected, and both rejections shaped this one:
an unbounded pg_advisory_lock is unusable because three of the eight
migrators run inside a plugin activate() that ToolPluginRuntime caps at
10s — turning a rare race into a deterministic boot failure; and a retry
that called pg_advisory_unlock without reading its boolean return could
not distinguish "released" from "never held", making the mechanism
silently inert.

STILL HELD BACK: the DynamicAgentRuntime rollback, now on its second
rejection. It does not cover the timeout path — withTimeout is a bare
Promise.race and does not cancel, so after the rollback runs and clears
the in-flight marker, the orphaned activate() continues and re-registers.
That is the same defect class already documented for ToolPluginRuntime,
and it wants a cancellation token rather than another rollback tweak.

5,224 pass, typecheck clean, ratchet held at 3,303.

* chore(470): resync wave2 with main (#549) — baseline 3,303 → 3,306

The channel-api work added 3 dev-platform references (test/packages).
Same legitimate raise as on the C5 branch.
@Weegy
Weegy deleted the fix/plugin-install-path-traversal branch August 14, 2026 06:53
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