fix(dispatch): require POST+password for /wheels/cli destructive ops, contain dbDump - #2947
Conversation
… contain dbDump Hardens the /wheels/cli bridge endpoint per the 2026-06-09 framework review (public-cli-endpoint package): - SEC-4/P7: state-changing commands (dbReset drops every table, plus migrate/seed/rollback/jobs/etc.) were reachable over unauthenticated GET, CSRF-fireable from any page a developer visits. They now require POST + loopback + the reload password, enforced by the new Public.cfc::$cliCommandIsMutating() / $cliMutationGateCheck() (consoleeval.cfm pattern, fail closed when no password is set). routes.cfm gains a POST route for /wheels/cli; the wheels CLI (Module.cfc, MigrationRunner.cfc) now sends mutating bridge commands as POST with the auto-detected reload password in the form body. - SEC-5/P8: dbDump --output went through a raw expandPath() + fileWrite(), so ../ traversal escaped the application root. New $cliResolveDumpPath() canonicalizes and confines to the web root (guideImage pattern). - P1: dbShell's SQL pass-through executed the dispatch param 'command' (the literal string "dbShell") as SQL, always threw, and clobbered the help text. The dead branch is removed; the help text survives. - P3: dbStatus discarded the migrator's real status field for a version-comparison heuristic that misclassified out-of-sequence pending migrations as applied, and read a never-set loadedAt field. New $cliFormatMigrationStatus() maps status == "migrated" directly. - P10: the preamble ran getCurrentMigrationVersion + $getDBType + getAvailableMigrations (O(N) $dbinfo round-trips) for every command, including jobs* worker polls. Discovery is now lazy per command, dbStatus/dbRollback reuse the discovered list, and $cliDatabaseType() memoizes the adapter probe per datasource. Deviation from the recommendation: the suggested additional ?confirm=1 flag is omitted - the only legitimate client (the wheels CLI) is non-interactive and would send it unconditionally, so it adds friction without protection beyond POST + loopback + password. The deprecated CommandBox CLI (cli/src) still sends GET for its mutating commands and now receives a clear 405 explaining the upgrade path. Verified on Lucee 7 + SQLite (worktree docker recipe): new CliEndpointHardeningSpec 25/25 pass, full security area 222/222 pass, plus live-endpoint checks of every gate branch and the dbShell/dbDump behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This PR correctly closes a real CSRF vector — unauthenticated GET requests could fire dbReset via a browser <img> tag. The gate implementation (POST + loopback check + constant-time password comparison) is sound, the test coverage is thorough, and the cross-engine notes are accurate. Two low-severity findings below; neither touches the gate logic itself.
Correctness
makeBridgePost — null error stream produces an NPE instead of a clean error
vendor/wheels/public/views/cli.cfm routes through makeBridgePost in cli/lucli/Module.cfc. On lines 6188–6190:
var responseCode = conn.getResponseCode();
var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");HttpURLConnection.getErrorStream() returns null when the server returns 4xx/5xx with no response body (per the Java docs: "If the server had an error but no error data was sent, this method will return null"). Passing null to Scanner.init() throws java.lang.NullPointerException. The outer try/catch in the caller rescues this into a MigrationError, but the message becomes "null" rather than something actionable. In practice the Wheels framework always sends a JSON body on gated rejections, so this is low-probability; it would only bite if something else is listening on the port.
The same pattern exists in the pre-existing makeHttpPost() at line 6220, so this isn't new debt, but makeBridgePost is new code in this PR. Suggested guard:
var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
if (IsNull(inputStream) || inputStream == javaCast("null", "")) {
return "";
}
var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");Security
cli.cfm:19 — password read from merged URL+form params instead of form body exclusively
password = StructKeyExists(request.wheels.params, "password") ? request.wheels.params.password : ""request.wheels.params merges URL query parameters and form fields. The PR body says the password is "sent as a form field to keep it out of the URL and access logs," but a POST to /wheels/cli?command=dbReset&password=secret would still satisfy the gate. The gate itself already requires POST (preventing <img> CSRF) and loopback (preventing remote reach), so this is not a bypass — but the URL-param path contradicts the stated design and would log the password in the server's access log.
Prefer reading from the form scope explicitly:
password = StructKeyExists(form, "password") ? form.password : ""This also makes the intent clearer for future readers.
Tests
Coverage is solid: 25 tests across $cliCommandIsMutating, $cliMutationGateCheck (GET, non-loopback, X-Forwarded-For bypass, empty password, missing key, wrong password, IPv4/IPv6/loopback-chain happy paths), $cliResolveDumpPath (traversal, nested paths, empty input), $cliFormatMigrationStatus (out-of-sequence, summary counts, key shape), and $cliDatabaseType memoization. BDD syntax, shared var state struct for closure-safe state — all correct.
One observation: dbDrop (line 82) and dbRestore (line 83) are listed as read-only in the spec. Both are currently stubs that return error messages directing users to their database tools (confirmed at cli.cfm:649–653 and cli.cfm:780–800), so the classification is accurate — but worth a comment in the spec so the next reader doesn't assume those entries are an oversight.
Commits
fix(dispatch): require POST+password for /wheels/cli destructive ops, contain dbDump — valid type, valid format, under 100 chars, sentence-case. ✓
Wheels Bot — Reviewer B (round 1)A's two findings are real and well-cited. One genuine missed issue: the SycophancyNone detected. A's TL;DR is positive but backed by cited code and test evidence. A did not approve despite touching a security subsystem. False positivesNone detected. Both findings check out against the diff:
Missed issues
for (local.migration in data.migrations) {
if (local.migration.version <= data.currentVersion) {
arrayAppend(local.appliedMigrations, local.migration);
}
}
local.targetIndex = arrayLen(local.appliedMigrations) - local.steps;
local.targetVersion = local.appliedMigrations[local.targetIndex].version;The PR fixed this exact heuristic for Concrete failure with a shared dev DB (0001 applied, 0002 pending, 0003 applied,
The fix is the same pattern used in Verdict alignmentA's two findings (a null NPE in new code and a security logging concern on a security-hardening PR) both justify REQUEST_CHANGES. COMMENTED leaves them advisory, which is too passive here — the stated PR design says the password is "sent as a form field to keep it out of the URL and access logs," and the code contradicts that intent. The gate logic is sound, but these are fixable issues in new code before merge. ConvergenceNot aligned. A needs to address the |
There was a problem hiding this comment.
Wheels Bot — Reviewer A response (round 1)
B's round-1 critique is correct on both counts. I concede the dbRollback P3 heuristic as a genuine missed issue and agree the verdict should be REQUEST_CHANGES. No defended points — B raised no false positives against my original findings.
Conceded points
-
dbRollbackP3 heuristic (missed issue). B is right. Thecase "dbRollback":block incli.cfm(diff lines 510–511) still doesif (local.migration.version <= data.currentVersion)to buildlocal.appliedMigrations. The PR itself documents the fix in$cliFormatMigrationStatus()and its comment at diff line 455 says "the old version-comparison heuristic was wrong" — butdbRollbackwas not updated in the same pass. In the shared-dev-DB scenario B describes (0001 applied, 0002 pending locally but 0003 applied by a peer),dbRollback steps=2would include 0002 in the applied list and target0001, rolling back only one real migration instead of two. The fix is to filterlocal.migration.status == "migrated"as$cliFormatMigrationStatus()does. -
Verdict upgrade to REQUEST_CHANGES. B correctly flags that COMMENTED leaves real, fixable bugs advisory on a security-hardening PR whose stated design ("password sent as form field to keep it out of access logs") is directly contradicted by the implementation. With three fixable issues now on the table — the null
getErrorStream()NPE in new code, the password-via-URL path, and thedbRollbackheuristic inconsistency — REQUEST_CHANGES is the appropriate gate.
Defended points
None. B identified no false positives in my original review.
Updated verdict
REQUEST_CHANGES: three fixable issues in new code before merge — null getErrorStream() NPE in makeBridgePost, password readable from URL params on a security-hardening PR, and dbRollback carrying the same P3 heuristic the PR explicitly fixed for dbStatus.
Summary
Hardens the
/wheels/cliCLI/MCP bridge endpoint. State-changing commands (dbResetdrops every table, plus migrate/seed/rollback/jobs maintenance, etc.) were reachable over unauthenticated GET — CSRF-fireable from any page a developer visits via<img src=".../wheels/cli?command=dbReset">. They now require POST + loopback + the reload password (the establishedconsoleeval.cfmpattern, fail-closed when no password is configured, constant-time SHA-256 compare). The gate lives incli.cfm's preamble so it covers both the new POST route and the legacy?controller=wheels&view=clidispatch path.dbDump --outputpath traversal is contained to the web root,dbShell's dead self-referential SQL branch is removed,dbStatusnow reports the migrator's real status field, and per-request migration discovery is lazy/memoized so 3-second job-worker polls no longer pay O(N)$dbinforound-trips.The wheels CLI (
cli/lucli) is updated in the same commit:Module.cfcandMigrationRunner.cfcsend mutating bridge commands as POST with the auto-detected reload password in the form body, sowheels migrate/seed/forget/pretendkeep working against the gated endpoint. Deliberate trade-offs: the recommended extra?confirm=1flag is omitted (the only legitimate client is non-interactive and would send it unconditionally), and the deprecated CommandBox CLI (cli/src) still sends GET for mutating commands and now receives a clear 405 explaining the upgrade path — closing the GET path is the fix.Findings addressed
/wheels/cliruns destructive DB operations over an unauthenticated GET request @vendor/wheels/public/views/cli.cfm:14-37(gate invocation),vendor/wheels/Public.cfc:55($cliCommandIsMutating()),vendor/wheels/Public.cfc:76($cliMutationGateCheck()),vendor/wheels/public/routes.cfm:28(new POST route); CLI sidecli/lucli/Module.cfc:6174(makeBridgePost()),cli/lucli/services/MigrationRunner.cfc:94-108.dbDumpwrites the dump to an attacker-controlled path with no containment check @vendor/wheels/Public.cfc:150($cliResolveDumpPath()— canonicalize + prefix-confine to the web root, theguideImagepattern), callervendor/wheels/public/views/cli.cfm:750refuses to write on empty resolution.dbShellSQL pass-through reuses the dispatch paramcommand, making the branch self-referential and always erroring @vendor/wheels/public/views/cli.cfm:802-834— dead branch removed with an explanatory comment; H2 shell help text preserved.dbStatusdiscards the migrator's real status field and substitutes a version-comparison heuristic @vendor/wheels/Public.cfc:203($cliFormatMigrationStatus()mapsstatus == "migrated"directly), consumed atvendor/wheels/public/views/cli.cfm:301; the line-257 self-assignment and the never-setloadedAtread are gone.vendor/wheels/public/views/cli.cfm:41-53— lazyneedsMigrations/needsVersion/needsDbTypeper-command lists;dbStatus/dbRollback/migrateUp/migrateDown/redoMigrationreusedata.migrations;vendor/wheels/Public.cfc:184($cliDatabaseType()) memoizes the adapter probe per datasource.Findings verified already-fixed
None — all findings in this package required code changes (staleRefs is empty; pre-fix lines
cli.cfm:257,:640,:731,:808and the GET-only route inroutes.cfmwere all confirmed present on origin/develop before this branch).Source
Internal multi-agent framework review 2026-06-09, wave 2, package
public-cli-endpoint.Tests
vendor/wheels/tests/specs/security/CliEndpointHardeningSpec.cfc(WheelsTest BDD,createObject("wheels.Public").$init()prior-art pattern) — 25 tests covering the mutating-command classifier, POST/loopback/X-Forwarded-For/password gate branches (including IPv4 + IPv6 loopback and fail-closed when no password is configured), dump-path traversal containment, and the P3 out-of-sequence-pending regression. All asserted helpers are new on this branch, so the spec fails against pre-fix code.securityarea 222/222 pass, plus live-endpoint checks of every gate branch and the dbShell/dbDump behavior. CI runs the full engine × DB matrix.cli/tests/specs/e2e/ServerCommandsTest.cfchas a whitebox assertion thatmakeHttpRequestappears within the first 800 chars ofrunMigration; the added gate logic pushes it to ~1087, so that single offset assertion goes stale if anyone runs the legacy suite. Worth a one-line follow-up; it does not affect CI.Cross-engine notes
Public.cfchelpers arepublicwith$prefix (mixin invariant New master - readme #7).cfheader/cfcontentuse explicit named args — noattributeCollection = arguments(Adobe 2023/2025 invariant Fixed bug in $findRoute() that causes blow up on unmatched named route #10).local.X-assigned-in-catch reads (BoxLang invariant New master #11): try-assigned locals are only read after a successful try.Left()/Right()in$cliResolveDumpPath()always receive lengths > 0 (canonical root is non-empty), avoiding the Lucee 7Left(str, 0)crash.var statestruct for closure state and no inline closures as constructor named args.Changelog
Entry deliberately omitted; consolidated at campaign end.
🤖 Generated with Claude Code