diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc
index 5d7021b7ca..522bf3b815 100644
--- a/cli/lucli/Module.cfc
+++ b/cli/lucli/Module.cfc
@@ -3682,9 +3682,14 @@ component extends="modules.BaseModule" {
var migrateUrl = "http://localhost:#serverPort#/wheels/cli?command=#command#&format=json";
+ // latest/up/down change the schema — the framework's /wheels/cli
+ // bridge requires POST + the reload password for state-changing
+ // commands. info/doctor are read-only and stay on GET.
+ var mutatingAction = listFindNoCase("latest,up,down", arguments.action) > 0;
+
var httpResult = "";
try {
- httpResult = makeHttpRequest(migrateUrl);
+ httpResult = mutatingAction ? makeBridgePost(migrateUrl) : makeHttpRequest(migrateUrl);
} catch (any httpErr) {
throw(
type = "MigrationError",
@@ -3762,9 +3767,10 @@ component extends="modules.BaseModule" {
// spurious query parameters before reaching that point.
var reconcileUrl = "http://localhost:#serverPort#/wheels/cli?command=#arguments.command#&version=#URLEncodedFormat(version)#&format=json";
+ // forget/pretend mutate the tracking table — POST + reload password.
var httpResult = "";
try {
- httpResult = makeHttpRequest(reconcileUrl);
+ httpResult = makeBridgePost(reconcileUrl);
} catch (any httpErr) {
throw(
type = "MigrationError",
@@ -3799,9 +3805,11 @@ component extends="modules.BaseModule" {
var renameUrl = "http://localhost:#serverPort#/wheels/cli?command=renameSystemTables&format=json"
& (arguments.dryRun ? "&dryRun=true" : "");
+ // renameSystemTables alters tables — POST + reload password (the
+ // dry-run preview rides the same gated command).
var httpResult = "";
try {
- httpResult = makeHttpRequest(renameUrl);
+ httpResult = makeBridgePost(renameUrl);
} catch (any httpErr) {
throw(
type = "MigrationError",
@@ -3869,9 +3877,10 @@ component extends="modules.BaseModule" {
seedUrl &= "&environment=#environment#";
}
+ // dbSeed writes data — POST + reload password.
var httpResult = "";
try {
- httpResult = makeHttpRequest(seedUrl);
+ httpResult = makeBridgePost(seedUrl);
} catch (any httpErr) {
throw(
type = "SeedError",
@@ -6154,6 +6163,39 @@ component extends="modules.BaseModule" {
return trim(response);
}
+ /**
+ * POST to a /wheels/cli bridge URL. State-changing bridge commands
+ * (migrate, seed, forget/pretend, rename-system-tables, ...) require
+ * POST + the reload password — the framework rejects them over GET so
+ * they cannot be CSRF-fired from a browser. The password is
+ * auto-detected from .env / config/settings.cfm and sent as a form
+ * field to keep it out of the URL and access logs.
+ */
+ private string function makeBridgePost(required string requestUrl) {
+ var javaUrl = createObject("java", "java.net.URL").init(arguments.requestUrl);
+ var conn = javaUrl.openConnection();
+ conn.setRequestMethod("POST");
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(120000);
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
+
+ var writer = createObject("java", "java.io.OutputStreamWriter").init(conn.getOutputStream(), "UTF-8");
+ writer.write("password=" & urlEncodedFormat(detectReloadPassword()));
+ writer.flush();
+ writer.close();
+
+ var responseCode = conn.getResponseCode();
+ var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
+ var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");
+ var response = "";
+ while (scanner.hasNextLine()) {
+ response &= scanner.nextLine() & chr(10);
+ }
+ scanner.close();
+ return trim(response);
+ }
+
/**
* Make an HTTP POST request with a JSON body and return the response
*/
diff --git a/cli/lucli/services/MigrationRunner.cfc b/cli/lucli/services/MigrationRunner.cfc
index b14416b3e4..e6247f87ae 100644
--- a/cli/lucli/services/MigrationRunner.cfc
+++ b/cli/lucli/services/MigrationRunner.cfc
@@ -85,6 +85,11 @@ component {
/**
* Run via HTTP to a running server (Phase 2-3 fallback).
+ *
+ * State-changing commands (latest/up/down) are sent as POST with the
+ * reload password — the framework's /wheels/cli bridge rejects them
+ * over GET so they cannot be CSRF-fired from a browser. Read-only
+ * commands like info stay on GET.
*/
public struct function runViaHttp(required numeric serverPort, required string action) {
var command = "";
@@ -96,8 +101,12 @@ component {
default: command = action;
}
- var url = "http://localhost:#serverPort#/wheels/cli?command=#command#&format=json";
- var httpService = new http(url=url, method="GET", timeout=120);
+ var mutating = listFindNoCase("migrateToLatest,migrateUp,migrateDown", command) > 0;
+ var bridgeUrl = "http://localhost:#serverPort#/wheels/cli?command=#command#&format=json";
+ var httpService = new http(url=bridgeUrl, method=(mutating ? "POST" : "GET"), timeout=120);
+ if (mutating) {
+ httpService.addParam(type="formfield", name="password", value=detectReloadPassword());
+ }
var httpResult = httpService.send().getPrefix();
if (httpResult.statusCode contains "200" && isJSON(httpResult.fileContent)) {
@@ -121,6 +130,33 @@ component {
}
}
+ /**
+ * Detect the reload password from .env or config/settings.cfm — the
+ * framework requires it for state-changing /wheels/cli commands.
+ * Mirrors Module.cfc::detectReloadPassword().
+ */
+ private string function detectReloadPassword() {
+ var envFile = variables.projectRoot & "/.env";
+ if (fileExists(envFile)) {
+ var envContent = fileRead(envFile);
+ var pwMatch = reFindNoCase("(?:WHEELS_)?RELOAD_PASSWORD\s*=\s*([^\r\n]+)", envContent, 1, true);
+ if (arrayLen(pwMatch.match) > 1 && len(trim(pwMatch.match[2]))) {
+ return trim(pwMatch.match[2]);
+ }
+ }
+
+ var settingsFile = variables.projectRoot & "/config/settings.cfm";
+ if (fileExists(settingsFile)) {
+ var settingsContent = fileRead(settingsFile);
+ var settingsMatch = reFindNoCase('reloadPassword\s*[=,]\s*"([^"]*)"', settingsContent, 1, true);
+ if (arrayLen(settingsMatch.match) > 1) {
+ return settingsMatch.match[2];
+ }
+ }
+
+ return "";
+ }
+
private void function ensureContext() {
if (!structKeyExists(application, "wheels") || !structKeyExists(application.wheels, "migrator")) {
throw(
diff --git a/vendor/wheels/Public.cfc b/vendor/wheels/Public.cfc
index 8d0466b83e..3bfc78ebcc 100644
--- a/vendor/wheels/Public.cfc
+++ b/vendor/wheels/Public.cfc
@@ -42,6 +42,190 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" {
}
}
+ /**
+ * Returns true when a /wheels/cli bridge command changes state — DB
+ * schema/data mutations (migrations, seeds, resets, job processing) or
+ * file writes (migration generators, dump output). These commands must
+ * pass $cliMutationGateCheck() before running; read-only commands stay
+ * reachable over plain GET for the CLI and the legacy GUI bridge.
+ *
+ * `diff` is read-only analysis unless asked to write migration files,
+ * so the caller passes that flag separately (2026-06-09 review SEC-4).
+ */
+ public boolean function $cliCommandIsMutating(required string command, boolean writesFiles = false) {
+ local.mutating = "createMigration,migrateTo,migrateToLatest,migrateUp,migrateDown,renameSystemTables,"
+ & "redoMigration,forgetVersion,pretendVersion,dbRollback,dbSeed,dbCreate,dbReset,dbSetup,dbDump,"
+ & "jobsProcessNext,jobsRetry,jobsPurge";
+ if (ListFindNoCase(local.mutating, arguments.command)) {
+ return true;
+ }
+ return CompareNoCase(arguments.command, "diff") == 0 && arguments.writesFiles;
+ }
+
+ /**
+ * Gate for state-changing /wheels/cli commands (2026-06-09 review SEC-4):
+ * the request must be a POST, from a loopback address (with no
+ * non-loopback X-Forwarded-For hop), carrying the reload password. A
+ * plain GET was CSRF-reachable — `
`
+ * on any page a developer visits would silently drop every table.
+ *
+ * Inputs arrive as arguments (instead of reading cgi directly) so the
+ * policy is unit-testable. Returns {allowed, statusCode, error}. Fails
+ * closed when no reload password is configured, matching consoleeval.cfm.
+ */
+ public struct function $cliMutationGateCheck(
+ required string requestMethod,
+ required string remoteAddr,
+ string forwardedFor = "",
+ string password = ""
+ ) {
+ if (arguments.requestMethod != "POST") {
+ return {
+ allowed = false,
+ statusCode = 405,
+ error = "This command changes state and must be sent as a POST request with the reload password. Upgrade the wheels CLI if it still sends GET."
+ };
+ }
+ if (!$isLoopbackAddress(arguments.remoteAddr)) {
+ return {allowed = false, statusCode = 403, error = "State-changing CLI commands are restricted to localhost."};
+ }
+ if (Len(Trim(arguments.forwardedFor))) {
+ for (local.ip in ListToArray(arguments.forwardedFor)) {
+ if (!$isLoopbackAddress(Trim(local.ip))) {
+ return {allowed = false, statusCode = 403, error = "State-changing CLI commands are restricted to localhost."};
+ }
+ }
+ }
+ if (
+ !StructKeyExists(application, "wheels")
+ || !StructKeyExists(application.wheels, "reloadPassword")
+ || !Len(Trim(application.wheels.reloadPassword))
+ ) {
+ return {
+ allowed = false,
+ statusCode = 403,
+ error = "State-changing CLI commands require a reload password. Set WHEELS_RELOAD_PASSWORD in .env or reloadPassword in config/settings.cfm."
+ };
+ }
+ if (!$cliSecureCompare(arguments.password, application.wheels.reloadPassword)) {
+ return {allowed = false, statusCode = 403, error = "Invalid reload password."};
+ }
+ return {allowed = true, statusCode = 200, error = ""};
+ }
+
+ /**
+ * True when the supplied IP address (or hostname) resolves to a loopback
+ * address. Empty input and unresolvable input both fail closed.
+ */
+ public boolean function $isLoopbackAddress(required string ipAddress) {
+ if (!Len(Trim(arguments.ipAddress))) {
+ return false;
+ }
+ try {
+ return CreateObject("java", "java.net.InetAddress").getByName(Trim(arguments.ipAddress)).isLoopbackAddress();
+ } catch (any e) {
+ return false;
+ }
+ }
+
+ /**
+ * Constant-time string comparison (hash both sides, compare digests via
+ * MessageDigest.isEqual) to prevent timing attacks on the reload
+ * password. Same construction as consoleeval.cfm / onapplicationstart.cfc.
+ */
+ public boolean function $cliSecureCompare(required string input, required string expected) {
+ return CreateObject("java", "java.security.MessageDigest").isEqual(
+ Hash(arguments.input, "SHA-256").getBytes("UTF-8"),
+ Hash(arguments.expected, "SHA-256").getBytes("UTF-8")
+ );
+ }
+
+ /**
+ * Resolves the dbDump `output` parameter to a canonical absolute path
+ * and confines it to the application's web root — the same
+ * canonicalize-and-confine pattern guideImage() uses. Returns "" when
+ * the path is empty or escapes the root via `../` traversal
+ * (2026-06-09 review SEC-5).
+ */
+ public string function $cliResolveDumpPath(required string output) {
+ if (!Len(Trim(arguments.output))) {
+ return "";
+ }
+ try {
+ local.canonicalRoot = CreateObject("java", "java.io.File").init(ExpandPath("/")).getCanonicalPath();
+ // Treat the requested path as web-root-relative. The old raw
+ // ExpandPath() resolved bare relative paths against the current
+ // template directory and let `../` climb out of the application
+ // entirely; java.io.File(parent, child) keeps absolute child
+ // paths contained too, and getCanonicalPath() collapses any
+ // remaining traversal before the confinement check below.
+ local.canonicalTarget = CreateObject("java", "java.io.File").init(local.canonicalRoot, arguments.output).getCanonicalPath();
+ } catch (any e) {
+ return "";
+ }
+ local.separator = CreateObject("java", "java.io.File").separator;
+ if (Right(local.canonicalRoot, 1) != local.separator) {
+ local.canonicalRoot &= local.separator;
+ }
+ if (CompareNoCase(Left(local.canonicalTarget, Len(local.canonicalRoot)), local.canonicalRoot) != 0) {
+ return "";
+ }
+ return local.canonicalTarget;
+ }
+
+ /**
+ * Returns the migrator adapter name for the application datasource,
+ * memoized in the application scope — $getDBType() costs a $dbinfo
+ * round-trip on every call and the driver behind a datasource cannot
+ * change without a reload (which rebuilds application.wheels and so
+ * clears this cache). Keyed by datasource name so a datasource swap
+ * re-probes (2026-06-09 review P10).
+ */
+ public string function $cliDatabaseType() {
+ local.dsName = application.wheels.dataSourceName;
+ if (!StructKeyExists(application.wheels, "$cliDbTypeCache")) {
+ application.wheels["$cliDbTypeCache"] = {};
+ }
+ if (!StructKeyExists(application.wheels.$cliDbTypeCache, local.dsName)) {
+ application.wheels.$cliDbTypeCache[local.dsName] = CreateObject("component", "wheels.migrator.Base").$getDBType();
+ }
+ return application.wheels.$cliDbTypeCache[local.dsName];
+ }
+
+ /**
+ * Formats the migrator's discovery list for the /wheels/cli dbStatus
+ * command, mapping the migrator's own status field ("migrated" or "")
+ * to applied/pending. The previous version-comparison heuristic
+ * (version <= currentVersion → "applied") misclassified out-of-sequence
+ * pending migrations as applied — the exact shared-dev-DB drift
+ * `migrate doctor` exists to surface (2026-06-09 review P3).
+ */
+ public struct function $cliFormatMigrationStatus(required array migrations) {
+ local.rv = {migrations = [], summary = {total = 0, applied = 0, pending = 0}};
+ for (local.migration in arguments.migrations) {
+ local.isApplied = local.migration.status == "migrated";
+ // getAvailableMigrations() does not track per-row apply
+ // timestamps; keep the key for CLI display compatibility
+ // (the CLI prints "-" when empty).
+ ArrayAppend(
+ local.rv.migrations,
+ {
+ version = local.migration.version,
+ description = local.migration.name,
+ status = local.isApplied ? "applied" : "pending",
+ appliedAt = ""
+ }
+ );
+ if (local.isApplied) {
+ local.rv.summary.applied++;
+ } else {
+ local.rv.summary.pending++;
+ }
+ }
+ local.rv.summary.total = ArrayLen(local.rv.migrations);
+ return local.rv;
+ }
+
/**
* Returns a struct { packages: [...], error: "" } populated from the
* wheels-packages registry. Short-circuits in production (defense in
diff --git a/vendor/wheels/public/routes.cfm b/vendor/wheels/public/routes.cfm
index a4d4862e0b..f86b9a28f4 100644
--- a/vendor/wheels/public/routes.cfm
+++ b/vendor/wheels/public/routes.cfm
@@ -25,6 +25,7 @@ mapper()
.post(name = "mcpPost", pattern = "mcp", to = "public##mcp")
.post(name = "consoleEval", pattern = "console/eval", to = "public##consoleeval")
.get(name = "cli", pattern = "cli", to = "public##cli")
+ .post(name = "cliPost", pattern = "cli", to = "public##cli")
.get(name = "packageEntry", pattern = "packages/[name]", to = "public##packageentry")
.get(name = "packageList", pattern = "packages", to = "public##packagelist")
.get(name = "pluginEntry", pattern = "plugins/[name]", to = "public##pluginentry")
diff --git a/vendor/wheels/public/views/cli.cfm b/vendor/wheels/public/views/cli.cfm
index c1c130f036..addd5c540e 100644
--- a/vendor/wheels/public/views/cli.cfm
+++ b/vendor/wheels/public/views/cli.cfm
@@ -1,16 +1,57 @@
-baseCfc = createObject("wheels.migrator.Base");
setting showDebugOutput="no";
migrator = application.wheels.migrator;
try {
+ local.cliCommand = StructKeyExists(request.wheels.params, "command") ? request.wheels.params.command : "";
+
+ // ── Security gate (2026-06-09 review SEC-4) ─────────────────────────
+ // State-changing commands must arrive as POST from loopback carrying
+ // the reload password — a plain GET here was CSRF-reachable (an
+ // tag on any page a developer visits could drop every table via
+ // dbReset). Read-only commands stay on GET for the CLI and legacy GUI.
+ local.writesMigrationFiles = StructKeyExists(request.wheels.params, "write") && request.wheels.params.write == "true";
+ if (Len(local.cliCommand) && $cliCommandIsMutating(local.cliCommand, local.writesMigrationFiles)) {
+ local.gate = $cliMutationGateCheck(
+ requestMethod = cgi.request_method,
+ remoteAddr = cgi.remote_addr,
+ forwardedFor = cgi.http_x_forwarded_for,
+ password = StructKeyExists(request.wheels.params, "password") ? request.wheels.params.password : ""
+ );
+ if (!local.gate.allowed) {
+ cfheader(statuscode = local.gate.statusCode);
+ cfcontent(type = "application/json");
+ WriteOutput(
+ SerializeJSON({
+ "success" = false,
+ "command" = local.cliCommand,
+ "message" = local.gate.error,
+ "messages" = local.gate.error
+ })
+ );
+ abort;
+ }
+ }
+
+ // ── Lazy migration discovery (2026-06-09 review P10) ────────────────
+ // getAvailableMigrations() instantiates every migration CFC (a $dbinfo
+ // round-trip each) and $getDBType() costs another probe, so only the
+ // commands that actually consume them pay — `routes`, `introspect`, and
+ // the jobs* commands job workers poll every few seconds skip discovery
+ // entirely. An empty command keeps the full legacy ping payload.
+ local.needsMigrations = !Len(local.cliCommand)
+ || ListFindNoCase("info,migrateUp,migrateDown,redoMigration,dbStatus,dbRollback", local.cliCommand) > 0;
+ local.needsVersion = local.needsMigrations || CompareNoCase(local.cliCommand, "dbVersion") == 0;
+ local.needsDbType = !Len(local.cliCommand)
+ || ListFindNoCase("info,doctor,dbSchema,dbCreate,dbReset,dbDump,dbRestore,dbShell", local.cliCommand) > 0;
+
"data" = {};
data["success"] = true;
data["datasource"] = application.wheels.dataSourceName;
data["wheelsVersion"] = application.wheels.version;
- data["currentVersion"] = migrator.getCurrentMigrationVersion();
- data["databaseType"] = baseCfc.$getDBType();
- data["migrations"] = migrator.getAvailableMigrations();
+ data["currentVersion"] = local.needsVersion ? migrator.getCurrentMigrationVersion() : "";
+ data["databaseType"] = local.needsDbType ? $cliDatabaseType() : "";
+ data["migrations"] = local.needsMigrations ? migrator.getAvailableMigrations() : [];
data["lastVersion"] = 0;
data["message"] = "";
data["messages"] = "";
@@ -20,9 +61,9 @@ try {
data.lastVersion = data.migrations[ArrayLen(data.migrations)].version;
}
- if (StructKeyExists(request.wheels.params, "command")) {
- data.command = request.wheels.params.command;
- switch (request.wheels.params.command) {
+ if (Len(local.cliCommand)) {
+ data.command = local.cliCommand;
+ switch (local.cliCommand) {
case "createMigration":
if (StructKeyExists(request.wheels.params, "migrationPrefix") && Len(request.wheels.params.migrationPrefix)) {
data.message = migrator.createMigration(
@@ -252,42 +293,15 @@ try {
// Database commands
case "dbStatus":
- // Return migration status
+ // Return migration status straight from the migrator's own
+ // status field — see Public.cfc::$cliFormatMigrationStatus()
+ // for why the old version-comparison heuristic was wrong.
+ // Reuses the list discovered in the preamble instead of
+ // running discovery a second time.
+ local.statusReport = $cliFormatMigrationStatus(data.migrations);
data.success = true;
- data.currentVersion = data.currentVersion;
- data.migrations = [];
-
- // Format migrations for CLI consumption
- for (local.migration in migrator.getAvailableMigrations()) {
- local.migrationInfo = {
- version = local.migration.version,
- description = local.migration.name,
- status = local.migration.status,
- appliedAt = local.migration.loadedAt ?: ""
- };
- if (local.migration.version <= data.currentVersion) {
- local.migrationInfo.status = "applied";
- } else {
- local.migrationInfo.status = "pending";
- }
- arrayAppend(data.migrations, local.migrationInfo);
- }
-
- // Add summary
- local.applied = 0;
- local.pending = 0;
- for (local.m in data.migrations) {
- if (local.m.status == "applied") {
- local.applied++;
- } else {
- local.pending++;
- }
- }
- data.summary = {
- total = arrayLen(data.migrations),
- applied = local.applied,
- pending = local.pending
- };
+ data.migrations = local.statusReport.migrations;
+ data.summary = local.statusReport.summary;
break;
case "dbVersion":
@@ -302,9 +316,10 @@ try {
local.steps = structKeyExists(request.wheels.params, "steps") ? request.wheels.params.steps : 1;
local.targetVersion = "";
- // Find target version based on steps
+ // Find target version based on steps. Reuses the list
+ // discovered in the preamble instead of re-discovering.
local.appliedMigrations = [];
- for (local.migration in migrator.getAvailableMigrations()) {
+ for (local.migration in data.migrations) {
if (local.migration.version <= data.currentVersion) {
arrayAppend(local.appliedMigrations, local.migration);
}
@@ -727,11 +742,19 @@ try {
data.dump = local.sqlDump;
data.message = "Database dump generated successfully. Use --output parameter to save to file.";
- // If output file specified, save it
+ // If output file specified, save it. The path is
+ // canonicalized and confined to the application root
+ // (2026-06-09 review SEC-5) — `../` traversal would
+ // otherwise make this an arbitrary-location file write.
if (structKeyExists(request.wheels.params, "output")) {
- local.outputFile = expandPath(request.wheels.params.output);
- fileWrite(local.outputFile, local.sqlDump);
- data.message = "Database dump saved to: " & request.wheels.params.output;
+ local.outputFile = $cliResolveDumpPath(request.wheels.params.output);
+ if (Len(local.outputFile)) {
+ fileWrite(local.outputFile, local.sqlDump);
+ data.message = "Database dump saved to: " & request.wheels.params.output;
+ } else {
+ data.success = false;
+ data.message = "Invalid output path: the dump file must resolve inside the application root.";
+ }
}
} catch (any e) {
@@ -805,21 +828,14 @@ try {
data.message &= chr(10) & "Option 2: Command Line" & chr(10);
data.message &= "java -cp [path-to-h2.jar] org.h2.tools.Shell" & chr(10);
- // If command parameter provided, execute it
- if (structKeyExists(request.wheels.params, "command")) {
- try {
- local.shellQuery = new Query();
- local.shellQuery.setDatasource(application.wheels.dataSourceName);
- local.shellQuery.setSQL(request.wheels.params.command);
- local.shellResult = local.shellQuery.execute().getResult();
-
- data.success = true;
- data.result = local.shellResult;
- data.message = "Command executed successfully.";
- } catch (any e) {
- data.message = "Error executing command: " & e.message;
- }
- }
+ // NOTE: an earlier revision tried to execute
+ // request.wheels.params.command as SQL here, but that
+ // param is always the literal dispatch value "dbShell",
+ // so the branch executed "dbShell" as SQL, always threw,
+ // and clobbered the help text above with an error
+ // (2026-06-09 review P1). An SQL pass-through would also
+ // need the POST + reload-password gate; use the console
+ // (`wheels console`) for ad-hoc statements instead.
} else {
// Provide database-specific guidance
data.message = "Database shell access requires command-line tools. ";
diff --git a/vendor/wheels/tests/specs/security/CliEndpointHardeningSpec.cfc b/vendor/wheels/tests/specs/security/CliEndpointHardeningSpec.cfc
new file mode 100644
index 0000000000..8108cfffe9
--- /dev/null
+++ b/vendor/wheels/tests/specs/security/CliEndpointHardeningSpec.cfc
@@ -0,0 +1,309 @@
+/**
+ * Hardens the /wheels/cli bridge endpoint (2026-06-09 framework review,
+ * public-cli-endpoint package):
+ *
+ * - SEC-4/P7: state-changing commands (dbReset drops every table) were
+ * reachable over unauthenticated GET — CSRF-fireable from any page a
+ * developer visits. $cliCommandIsMutating() classifies the commands and
+ * $cliMutationGateCheck() enforces POST + loopback + reload password.
+ * - SEC-5/P8: dbDump wrote its output through a raw expandPath(), so ../
+ * traversal escaped the application root. $cliResolveDumpPath()
+ * canonicalizes and confines to the web root (guideImage pattern).
+ * - P3: dbStatus replaced the migrator's real status field with a
+ * version-comparison heuristic that misclassified out-of-sequence
+ * pending migrations as applied. $cliFormatMigrationStatus() maps the
+ * real field instead.
+ * - P10: $cliDatabaseType() memoizes the $dbinfo-backed adapter probe so
+ * polled commands do not re-probe on every request.
+ */
+component extends="wheels.WheelsTest" {
+
+ function run() {
+
+ describe("/wheels/cli endpoint hardening", () => {
+
+ // Shared struct (not bare locals) so the beforeEach/afterEach/it
+ // closures reliably see the same state on every engine.
+ var state = {publicCfc = "", originalReloadPassword = "", hadReloadPassword = false};
+
+ beforeEach(() => {
+ state.publicCfc = createObject("component", "wheels.Public").$init();
+ state.hadReloadPassword = StructKeyExists(application.wheels, "reloadPassword");
+ if (state.hadReloadPassword) {
+ state.originalReloadPassword = application.wheels.reloadPassword;
+ }
+ });
+
+ afterEach(() => {
+ if (state.hadReloadPassword) {
+ application.wheels.reloadPassword = state.originalReloadPassword;
+ } else {
+ StructDelete(application.wheels, "reloadPassword");
+ }
+ StructDelete(application.wheels, "$cliDbTypeCache");
+ });
+
+ describe("$cliCommandIsMutating() classification", () => {
+
+ it("classifies destructive and state-changing commands as mutating", () => {
+ var mutating = [
+ "dbReset",
+ "dbSetup",
+ "dbSeed",
+ "dbCreate",
+ "dbDump",
+ "dbRollback",
+ "migrateTo",
+ "migrateToLatest",
+ "migrateUp",
+ "migrateDown",
+ "redoMigration",
+ "createMigration",
+ "renameSystemTables",
+ "forgetVersion",
+ "pretendVersion",
+ "jobsProcessNext",
+ "jobsRetry",
+ "jobsPurge"
+ ];
+ for (var name in mutating) {
+ expect(state.publicCfc.$cliCommandIsMutating(name)).toBeTrue("expected #name# to be mutating");
+ }
+ });
+
+ it("leaves read-only commands reachable without the gate", () => {
+ var readOnly = [
+ "info",
+ "doctor",
+ "dbStatus",
+ "dbVersion",
+ "dbSchema",
+ "dbShell",
+ "dbDrop",
+ "dbRestore",
+ "routes",
+ "introspect",
+ "jobsStatus",
+ "jobsMonitor"
+ ];
+ for (var name in readOnly) {
+ expect(state.publicCfc.$cliCommandIsMutating(name)).toBeFalse("expected #name# to be read-only");
+ }
+ });
+
+ it("treats diff as read-only analysis unless it writes migration files", () => {
+ expect(state.publicCfc.$cliCommandIsMutating("diff")).toBeFalse();
+ expect(state.publicCfc.$cliCommandIsMutating("diff", false)).toBeFalse();
+ expect(state.publicCfc.$cliCommandIsMutating("diff", true)).toBeTrue();
+ });
+
+ it("matches command names case-insensitively like the dispatch switch", () => {
+ expect(state.publicCfc.$cliCommandIsMutating("DBRESET")).toBeTrue();
+ expect(state.publicCfc.$cliCommandIsMutating("migratetolatest")).toBeTrue();
+ });
+
+ });
+
+ describe("$cliMutationGateCheck() policy", () => {
+
+ beforeEach(() => {
+ application.wheels.reloadPassword = "test-secret-123";
+ });
+
+ it("rejects GET requests with a 405", () => {
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "GET",
+ remoteAddr = "127.0.0.1",
+ password = "test-secret-123"
+ );
+ expect(gate.allowed).toBeFalse();
+ expect(gate.statusCode).toBe(405);
+ });
+
+ it("rejects POST from a non-loopback address", () => {
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "8.8.8.8",
+ password = "test-secret-123"
+ );
+ expect(gate.allowed).toBeFalse();
+ expect(gate.statusCode).toBe(403);
+ });
+
+ it("rejects a non-loopback X-Forwarded-For hop (proxy bypass)", () => {
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "127.0.0.1",
+ forwardedFor = "127.0.0.1, 8.8.8.8",
+ password = "test-secret-123"
+ );
+ expect(gate.allowed).toBeFalse();
+ expect(gate.statusCode).toBe(403);
+ });
+
+ it("fails closed when the configured reload password is empty", () => {
+ application.wheels.reloadPassword = "";
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "127.0.0.1",
+ password = ""
+ );
+ expect(gate.allowed).toBeFalse();
+ expect(gate.statusCode).toBe(403);
+ });
+
+ it("fails closed when the reload password key is missing entirely", () => {
+ StructDelete(application.wheels, "reloadPassword");
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "127.0.0.1",
+ password = "anything"
+ );
+ expect(gate.allowed).toBeFalse();
+ expect(gate.statusCode).toBe(403);
+ });
+
+ it("rejects a wrong reload password", () => {
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "127.0.0.1",
+ password = "wrong-password"
+ );
+ expect(gate.allowed).toBeFalse();
+ expect(gate.statusCode).toBe(403);
+ });
+
+ it("allows POST from IPv4 loopback with the correct password", () => {
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "127.0.0.1",
+ password = "test-secret-123"
+ );
+ expect(gate.allowed).toBeTrue();
+ expect(gate.error).toBe("");
+ });
+
+ it("allows POST from IPv6 loopback with the correct password", () => {
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "::1",
+ password = "test-secret-123"
+ );
+ expect(gate.allowed).toBeTrue();
+ });
+
+ it("allows loopback-only X-Forwarded-For chains", () => {
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "127.0.0.1",
+ forwardedFor = "127.0.0.1, ::1",
+ password = "test-secret-123"
+ );
+ expect(gate.allowed).toBeTrue();
+ });
+
+ it("treats an empty remote address as non-loopback (fail closed)", () => {
+ var gate = state.publicCfc.$cliMutationGateCheck(
+ requestMethod = "POST",
+ remoteAddr = "",
+ password = "test-secret-123"
+ );
+ expect(gate.allowed).toBeFalse();
+ });
+
+ });
+
+ describe("$cliResolveDumpPath() containment (SEC-5)", () => {
+
+ it("resolves a relative filename inside the web root", () => {
+ var resolved = state.publicCfc.$cliResolveDumpPath("backup.sql");
+ expect(Len(resolved)).toBeGT(0);
+ expect(resolved).toInclude("backup.sql");
+ });
+
+ it("resolves a nested relative path inside the web root", () => {
+ var resolved = state.publicCfc.$cliResolveDumpPath("db/dumps/backup.sql");
+ expect(Len(resolved)).toBeGT(0);
+ expect(resolved).toInclude("backup.sql");
+ });
+
+ it("rejects traversal that escapes the web root", () => {
+ expect(state.publicCfc.$cliResolveDumpPath("../../../../../../tmp/evil.sql")).toBe("");
+ });
+
+ it("rejects traversal hidden behind a legitimate prefix", () => {
+ expect(state.publicCfc.$cliResolveDumpPath("db/../../../../../../../tmp/evil.sql")).toBe("");
+ });
+
+ it("rejects an empty output path", () => {
+ expect(state.publicCfc.$cliResolveDumpPath("")).toBe("");
+ expect(state.publicCfc.$cliResolveDumpPath(" ")).toBe("");
+ });
+
+ });
+
+ describe("$cliFormatMigrationStatus() real-status mapping (P3)", () => {
+
+ it("reports out-of-sequence pending migrations as pending, not applied", () => {
+ // A peer applied 0003 but this branch's 0002 has not run —
+ // the exact shared-dev-DB drift `migrate doctor` surfaces.
+ // The old `version <= currentVersion` heuristic called
+ // 0002 "applied" because the DB sat at version 0003.
+ var report = state.publicCfc.$cliFormatMigrationStatus([
+ {version = "20240101000001", name = "create_users", status = "migrated"},
+ {version = "20240101000002", name = "branch_only_migration", status = ""},
+ {version = "20240101000003", name = "create_orders", status = "migrated"}
+ ]);
+ expect(report.migrations[1].status).toBe("applied");
+ expect(report.migrations[2].status).toBe("pending");
+ expect(report.migrations[3].status).toBe("applied");
+ });
+
+ it("summarizes counts from the migrator's own status field", () => {
+ var report = state.publicCfc.$cliFormatMigrationStatus([
+ {version = "001", name = "a", status = "migrated"},
+ {version = "002", name = "b", status = ""},
+ {version = "003", name = "c", status = "migrated"}
+ ]);
+ expect(report.summary.total).toBe(3);
+ expect(report.summary.applied).toBe(2);
+ expect(report.summary.pending).toBe(1);
+ });
+
+ it("keeps the appliedAt key (empty) and maps name to description", () => {
+ var report = state.publicCfc.$cliFormatMigrationStatus([
+ {version = "001", name = "create_users", status = "migrated"}
+ ]);
+ expect(StructKeyExists(report.migrations[1], "appliedAt")).toBeTrue();
+ expect(report.migrations[1].appliedAt).toBe("");
+ expect(report.migrations[1].description).toBe("create_users");
+ });
+
+ it("returns an empty report for an empty discovery list", () => {
+ var report = state.publicCfc.$cliFormatMigrationStatus([]);
+ expect(ArrayLen(report.migrations)).toBe(0);
+ expect(report.summary.total).toBe(0);
+ });
+
+ });
+
+ describe("$cliDatabaseType() memoization (P10)", () => {
+
+ it("probes the adapter name for the application datasource", () => {
+ var dbType = state.publicCfc.$cliDatabaseType();
+ expect(Len(dbType)).toBeGT(0);
+ });
+
+ it("returns the cached value on subsequent calls instead of re-probing", () => {
+ application.wheels["$cliDbTypeCache"] = {};
+ application.wheels.$cliDbTypeCache[application.wheels.dataSourceName] = "MemoizedSentinel";
+ expect(state.publicCfc.$cliDatabaseType()).toBe("MemoizedSentinel");
+ });
+
+ });
+
+ });
+
+ }
+
+}