The Delete Tool Did Not Ask: Wheels Ships Nine CVEs, Eleven Advisories, and an MCP Surface That Trusted Its Caller

Nine CVEs for the Wheels CFML framework published inside a single second and a half on 16 October 2026 — CVE-2026-111443 through CVE-2026-111451, all timestamped between 04:18:30.133Z and 04:18:31.667Z, all from cve@mitre.org, all at NVD status Received. On the surface this is an ordinary framework batch: a 9.8 SQL injection, an 8.6 exposed dev-tools surface, two 8.1 object-reference flaws, and a tail of medium-severity information disclosure. The reason it belongs here is one sentence in the lowest-scored high, CVE-2026-111451 (7.8, CWE-78), which states the threat source plainly, and which we quote verbatim:

“Several Wheels CLI commands use caller-supplied values without validating or escaping them. A value can come from a CI variable, a branch name, or an AI agent driving the CLI’s MCP tools.”

That is a maintainer, in a published advisory, listing an autonomous agent alongside CI and git as a routine untrusted-input channel into a developer CLI. Three of the eight defects in that advisory are annotated as reachable through MCP specifically, and the workaround section ends with an instruction that reads like a deployment policy: “Don’t expose the Wheels MCP server to agents that handle untrusted content.”

Wheels (wheels-dev/wheels, 203 stars, 111 forks, Apache-2.0) is a ColdFusion framework “inspired by Ruby on Rails,” in the repository since January 2011. It is not a large project by star count, and that is part of the point: the MCP tool surface arrived here the same way it is arriving everywhere, bolted onto an existing CLI, inheriting the trust model of a tool that previously only ever ran at a developer’s own keyboard.

The MCP tools that write, delete, and interpolate

Read as a list of sinks rather than a list of severities, GHSA-cghp-86c6-6fjc / CVE-2026-111451 describes eight defects in the Wheels CLI across releases 4.0.0 through 4.1.1, fixed in 4.1.2. The three the advisory marks as MCP-reachable are the ones that matter for anything driving this CLI programmatically:

  • Recursive deletion, no confirmation. The package name passed to wheels packages remove is not validated, so wheels packages remove .. deletes the project’s contents. The advisory states the consequence and the exposure in the same breath: “The command is exposed as an MCP tool and does not ask for confirmation.”
  • Generator path traversal. wheels generate controller|view|scaffold accepts names containing ../ and writes .cfm files outside the project. “The command is exposed via MCP.”
  • Code injection into generated source. Property, model, action, helper-function, migration and app names are interpolated into generated CFML without validation, so a #…# expression or a quote in a name “become[s] executable code at migrate or request time.” Also exposed via MCP.

The remaining five are the familiar developer-tool hazards: extraction into the whole vendor/ directory so a compromised package can overwrite vendor/wheels; wheels destroy following a symlinked path out of the project; stored XSS in the scaffold show view since 4.1.0; and wheels start|stop|status --engine=rustcfml acting on a recorded process ID without checking the process belongs to this project, so a reused PID after a crash gets terminated.

But the headline defect in that advisory is the deploy path, and it is worth stating precisely because the payload is not exotic. wheels deploy interpolated the --release/--version value and the rollback argument into remote shell commands, container names and image tags without escaping. The advisory’s own example is feat$(cmd), with the detail that makes it a supply-chain problem rather than a curiosity: it “is a valid git ref name,” and it “runs commands on every deploy host as the SSH user, and on the CI runner during the build step.” A branch name is not a field anyone validates, which is exactly the lesson from the eighteen filename-command-injection CVEs VulnCheck shipped two days earlier and from Komodo’s container-name shell injection: the attacker supplies a value whose job description is “identifier,” and three stack frames down it becomes grammar.

The 4.1.2 fix is the right shape, and we read it in the v4.1.1...v4.1.2 comparison (91 commits, 300 files) rather than taking the release note on trust. cli/lucli/services/deploy/config/Validator.cfc gains 77 lines, including strict format validators for image references and hosts, built as negated character classes with explicit bounds. The host validator’s source comment names the argument-injection variant that escaping alone would miss, quoted verbatim:

// Hosts reach ssh and remote shell commands: allow host-name, IP,
// user@host:port and [IPv6] characters only, starting with an
// alphanumeric or '[' (a leading '-' would read as an ssh option).

The image validator carries the reason these values are format-checked rather than quoted, which is a genuinely useful distinction: the references “go into local bash -c build commands and remote docker pull/run,” and a pre-existing comment at the top of the file notes that some names are “piped to xargs docker rm -f, so they must be format-validated rather than quoted.” Quoting does not survive every downstream consumer; a restricted alphabet does.

The unauthenticated file-write path that nobody numbered

Here is the finding that is not in any of the nine CVEs. The 4.1.2 release notes (1 October 2026, 12:47:22Z) carry a Security section, and one of its entries describes a defect with a strictly worse profile than most of the batch, quoted verbatim:

“The deprecated HTTP MCP endpoint (/wheels/mcp) is now read-only: only the read-only analyze tool can be listed or called there. Mutating and command-executing tools (generate, migrate, test, validate, server, reload, develop) are refused with a message pointing to the canonical stdio MCP server (wheels mcp wheels). This removes an unauthenticated file-write path that was reachable over the development-only HTTP surface (including via a GET whose query promoted it to a POST).”

So the framework shipped an HTTP-transport MCP server that advertised and executed its full mutating toolset — file generation, migrations, server control, reload — behind the password-less /wheels/* development surface, with a GET that could be promoted to a POST. We confirmed the fix in source: vendor/wheels/public/mcp/McpServer.cfc gains 35 lines and loses one between v4.1.1 and v4.1.2, adding an allow-list consulted in both tools/list and tools/call. The implementation is a one-liner with a comment that states the design rule correctly:

/**
 * The read-only tools that may be called over the DEPRECATED HTTP MCP surface
 * (this component backs only /wheels/mcp; the canonical MCP is the stdio
 * server, `wheels mcp wheels`). Everything else — generate, migrate, test,
 * validate, server, reload, develop — writes files, changes state, or runs
 * commands, so it is refused here and available only over stdio. Fail-closed:
 * a tool not named here is treated as mutating.
 */
private string function $httpReadOnlyTools() {
    return "analyze";
}

Two details deserve credit. The listing is filtered with the same allow-list that gates execution, “so the listing matches what tools/call will actually permit” — a divergence between advertised and enforced capability is its own bug class, and they closed both ends. And the default is inverted correctly: a tool not named in the allow-list is treated as mutating, so the next tool added to this server is refused over HTTP until someone deliberately decides otherwise. That is the opposite of the pattern we documented in Roo Code’s five approval bypasses, where the gate and the shell parsed different languages.

The scoping caveat, stated honestly: this surface is development-only and blocked in production, and CVE-2026-111446 / GHSA-6v6c-p375-vxw9 (8.6) covers the broader problem of those /wheels/* tools being reachable from a network at all. But the specific unauthenticated file-write through the HTTP MCP endpoint has no CVE, no advisory of its own, and exists only as a bullet in a release note — which means no scanner, dependency bot or SBOM tool will ever tell an operator about it.

Nine CVEs, eleven advisories, and the two that were left out

The repository’s advisory list holds eleven entries. We mapped each of the nine CVEs to its GHSA through the NVD reference URLs, which leaves exactly two advisories with no CVE assigned:

  • GHSA-96rm-h22v-925x — rated critical, 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, CWE-89). SQL injection through dynamic finders (findOneByEmail), the query builder’s where(property, value), string and UUID key lookups, and validatesUniquenessOf. The quoting doubled single quotes, but the where= parser the quoted string was fed back through does not treat '' as an escaped quote: it ends the literal at the first quote and parses the rest as SQL. The advisory states the blast radius without hedging — “including authentication, password-reset and token lookups” — and notes the construction is present in every release from 1.1.8 through 4.1.1. It also names the reason applications trusted it: “The guides describe these values as automatically parameterized, so applications reasonably rely on this.”
  • GHSA-x3cm-2j3q-jgg4 — 5.0, CWE-923. The CLI sent a project’s reload password, and code for evaluation, to whatever local server answered on the probed port, because it never verified the server belonged to the current project. Fixed in 4.1.1.

A 9.8 that spans fifteen years of releases and reaches authentication lookups is the single most consequential item in the whole set, and it is the one with no CVE identifier. Note also that GitHub’s advisory records carry cve_id: null for all eleven entries, including the nine that now demonstrably have CVEs — the linkage exists only in one direction, as a reference URL on the NVD side.

The CWE assignments diverge between the two records in a way worth noting for anyone correlating them. NVD classifies CVE-2026-111445 as CWE-669 where the advisory says CWE-639; CVE-2026-111446 as CWE-863 against the advisory’s CWE-200 and CWE-290; CVE-2026-111447 as CWE-472 against CWE-639 and CWE-915; and CVE-2026-111443 as CWE-348 against CWE-284. The CVSS vectors, by contrast, match exactly on every record we checked. Neither classification is wrong; they are answering slightly different questions, and a pipeline keyed on CWE will group these batches differently depending on which feed it read.

The scanner gap, measured

Checked directly at the time of writing: api.osv.dev returns "Vulnerability not found" for CVE-2026-111444, CVE-2026-111446 and CVE-2026-111451. The mechanism is visible in the advisories themselves — the affected packages are recorded with ecosystem: "other" and the names wheels and wheels-cli. That is not a coordinate any scanner resolves: not npm, not PyPI, not Maven, and not ForgeBox, which is where CFML packages actually live. This is the same structural failure we recorded for OPNsense’s five-CVE batch and for runwasi: a published advisory with a shipped patch and a real CVE, sitting in no feed that any automated tool consults.

The timeline is otherwise a credit to the maintainers, and it is unusual enough to state:

  • 28 September 2026, 17:01:19Z — v4.1.1 released. GHSA-x3cm-2j3q-jgg4 published 17:09:34Z, eight minutes later.
  • 1 October 2026, 12:47:22Z — v4.1.2 released, carrying the CLI fixes (CVE-2026-111451), the dynamic-finder SQL injection fix, and the HTTP MCP read-only change. Four advisories published 13:03–13:04Z, roughly sixteen minutes later.
  • 11 October 2026, 23:55:29Z — v4.2.0 released. GHSA-m63x-8m98-225v published 23:56:32Z — sixty-three seconds after the release; the remaining four at 00:02:27–00:02:31Z.
  • 16 October 2026, 04:18:30–31Z — the nine CVE records publish, four days and four hours after the fixed release.

Patch first, advisory within the minute, CVE later. That ordering is correct and increasingly rare. What it does not fix is discoverability: an operator running Wheels 4.1.1 today learns about this from a release note or from this kind of write-up, because the records exist in a database their tooling cannot query.

What to do

  • Upgrade to 4.2.0 (11 October 2026). 4.1.2 closes the CLI shell injection, the dynamic-finder SQL injection and the HTTP MCP write path; 4.2.0 is required for the five October advisories that follow it, including the MySQL/MariaDB backslash case (CVE-2026-111444) and the dev-tools peer-address check (CVE-2026-111446).
  • Treat the stdio MCP server as a privileged shell. It retains every mutating tool by design — that is the documented split. The advisory’s own guidance is the floor, not the ceiling: do not expose it to an agent that handles untrusted content, and if an agent drives packages remove or generate, the containment now lives in 4.1.2’s canonical-path checks rather than in a confirmation prompt that never existed.
  • Stop passing branch names and CI variables into wheels deploy. Even on a fixed CLI, treat --release, --version and rollback as values that reach a remote shell, and pin them to a charset you control rather than to whatever the VCS allowed.
  • Audit for development mode in production images. CVE-2026-111446 explicitly names “production images generated by wheels deploy init before 4.2.0” as an affected population. Block /wheels/* and /wheels-docs at the proxy regardless, and expect the documented 4.2.0 behaviour change: Docker-published dev ports now return 403 unless devToolsAllowedRemoteAddresses is set.
  • Set errorEmailToAddress explicitly, or sendEmailOnError=false. CVE-2026-111450 derives the default production error-email recipient from the Host header of whichever request triggered the cold start, and the derivation dates to 0.9.x. The wheels new template leaves the setting commented out.
  • Do not rely on model scopes for tenancy until you are on 4.2.0. CVE-2026-111445 means findByKey() and exists(key=) discarded the scope chain, which breaks exactly the ownedBy(user) pattern the multi-tenancy guide recommends.
  • Add the two un-numbered advisories to your own tracking. GHSA-96rm-h22v-925x in particular will never reach you through a CVE feed, and it covers every release from 1.1.8.

Verification note: all nine CVE identifiers, publication timestamps, CVSS 3.1 vectors, CWE assignments, vulnStatus values, source identifiers and reference URLs were read first-hand from the NVD 2.0 API, and the CVE-to-GHSA mapping was derived from those reference URLs. All eleven advisory records — summaries, impact text, affected and patched version ranges, severities, CVSS vectors, CWE lists, ecosystem and package coordinates, and publication timestamps — were read first-hand from the GitHub security-advisories API for wheels-dev/wheels; every quoted sentence is verbatim from those advisories or from the v4.1.2 release notes. The v4.1.1-to-v4.1.2 comparison (91 commits, 300 files), the McpServer.cfc and Validator.cfc diffs, and all code and comments shown are quoted verbatim from the GitHub compare API. Release tags, publication timestamps and repository metadata (203 stars, 111 forks, Apache-2.0, created 13 January 2011) come from the GitHub API. The OSV absences for CVE-2026-111444, CVE-2026-111446 and CVE-2026-111451 were measured directly against api.osv.dev at the time of writing. Brave Search was used for discovery only. We installed nothing, deployed no Wheels application, started no development server, contacted no MCP endpoint, authored no package or generator name, crafted no branch name or image reference, and executed no proof of concept. We found no evidence of exploitation and do not imply any.

Sources: