Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
*/
Expand Down
40 changes: 38 additions & 2 deletions cli/lucli/services/MigrationRunner.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand All @@ -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)) {
Expand All @@ -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(
Expand Down
184 changes: 184 additions & 0 deletions vendor/wheels/Public.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<img src=".../wheels/cli?command=dbReset">`
* 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
Expand Down
1 change: 1 addition & 0 deletions vendor/wheels/public/routes.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading