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"); + }); + + }); + + }); + + } + +}