From 8ed44e8ebfeea388d6ff6b0b222282e499a361c6 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 28 Aug 2026 17:14:05 -0700 Subject: [PATCH 1/3] refactor(view): extract helpers from procedural templates (64/60/34/33/30/30 to max 25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit debug.cfm 64, consoleeval.cfm 60, cfmlerror.cfm 34, mcp.cfm 33, plugins.cfm 30, and command.cfm 30 — branchy data logic moved into local $-prefixed functions in each file. Render markup byte-identical; verified live: debug bar panels match baseline, plugins headings match, console eval POST returns {TYPE:number, RESULT:5}, wheels migrate info works through the command bridge. Fixes a #-escaping bug in the debug env-color extraction (##dc3545 etc. — # interpolates in CFScript strings too) caught by hand-testing. Signed-off-by: Peter Amiri --- vendor/wheels/events/onerror/cfmlerror.cfm | 115 +++--- vendor/wheels/events/onrequestend/debug.cfm | 112 ++--- vendor/wheels/public/migrator/command.cfm | 212 +++++----- vendor/wheels/public/views/consoleeval.cfm | 300 ++++++++------ vendor/wheels/public/views/mcp.cfm | 426 ++++++++++---------- vendor/wheels/public/views/plugins.cfm | 17 +- 6 files changed, 655 insertions(+), 527 deletions(-) diff --git a/vendor/wheels/events/onerror/cfmlerror.cfm b/vendor/wheels/events/onerror/cfmlerror.cfm index 961588a704..0ca1326287 100644 --- a/vendor/wheels/events/onerror/cfmlerror.cfm +++ b/vendor/wheels/events/onerror/cfmlerror.cfm @@ -1,3 +1,64 @@ + + function $cfmlErrorTagContext(required struct exception) { + if ( + StructKeyExists(arguments.exception, "cause") + && StructKeyExists(arguments.exception.cause, "tagContext") + && ArrayLen(arguments.exception.cause.tagContext) + ) { + return Duplicate(arguments.exception.cause.tagContext); + } else if ( + StructKeyExists(arguments.exception, "rootCause") + && StructKeyExists(arguments.exception.rootCause, "tagContext") + && ArrayLen(arguments.exception.rootCause.tagContext) + ) { + return Duplicate(arguments.exception.rootCause.tagContext); + } else if ( + StructKeyExists(arguments.exception, "tagContext") + && ArrayLen(arguments.exception.tagContext) + ) { + return Duplicate(arguments.exception.tagContext); + } + return []; + } + + function $cfmlErrorNormalizePath(required string path) { + local.norm = arguments.path; + local.norm = ReReplace(local.norm, "[" & Chr(92) & "(.*?)" & Chr(92) & "]", "." & Chr(92) & "1", "all"); + local.norm = ReReplace(local.norm, "^" & Chr(92) & ".", "", "one"); + return local.norm; + } + + function $cfmlErrorSanitizeScope(required struct scope, required string skip, required string scopeName) { + local.hide = "wheels"; + local.sanitizedScope = Duplicate(arguments.scope); + for (local.j in ListToArray(arguments.skip)) { + local.normalizedPath = $cfmlErrorNormalizePath(local.j); + if (local.normalizedPath CONTAINS "." AND ListFirst(local.normalizedPath, ".") EQ arguments.scopeName) { + local.relativePath = ListRest(local.normalizedPath, "."); + local.keyList = ListToArray(local.relativePath, "."); + local.ref = local.sanitizedScope; + local.depth = ArrayLen(local.keyList); + for (local.k = 1; local.k LTE local.depth; local.k++) { + local.key = local.keyList[local.k]; + if (local.k EQ local.depth) { + if (StructKeyExists(local.ref, local.key)) { + StructDelete(local.ref, local.key); + } + } else { + if (StructKeyExists(local.ref, local.key) AND IsStruct(local.ref[local.key])) { + local.ref = local.ref[local.key]; + } else { + break; + } + } + } + } else if (ListFindNoCase(arguments.skip, arguments.scopeName)) { + local.hide = ListAppend(local.hide, local.j); + } + } + return {hide = local.hide, sanitizedScope = local.sanitizedScope}; + } +
@@ -23,24 +84,7 @@ - - - - - - - +
@@ -140,38 +184,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +
diff --git a/vendor/wheels/events/onrequestend/debug.cfm b/vendor/wheels/events/onrequestend/debug.cfm index 0a2ad92d7c..9485912fad 100644 --- a/vendor/wheels/events/onrequestend/debug.cfm +++ b/vendor/wheels/events/onrequestend/debug.cfm @@ -1,10 +1,67 @@ + + function $debugBarSkipRequest(required struct reqHeaders) { + return (StructKeyExists(arguments.reqHeaders, "X-Requested-With") AND arguments.reqHeaders["X-Requested-With"] IS "XMLHttpRequest") + OR (StructKeyExists(arguments.reqHeaders, "HX-Request")) + OR (StructKeyExists(arguments.reqHeaders, "Turbo-Frame")) + OR (StructKeyExists(arguments.reqHeaders, "X-Fetch") AND arguments.reqHeaders["X-Fetch"] IS "true") + OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.format)); + } + + function $debugEnvColor(required string envClass) { + if (arguments.envClass IS "production") { + return "##dc3545"; + } else if (arguments.envClass IS "testing") { + return "##fd7e14"; + } else if (arguments.envClass IS "maintenance") { + return "##ffc107"; + } + return "##28a745"; + } + + function $debugTimingBreakdown(required struct execution) { + local.timingBreakdown = []; + if (arguments.execution.total GT 0) { + local.keys = StructSort(arguments.execution, "numeric", "desc"); + for (local.ti = 1; local.ti LTE ArrayLen(local.keys); local.ti++) { + local.tkey = local.keys[local.ti]; + if (local.tkey IS NOT "total" AND arguments.execution[local.tkey] GT 0) { + ArrayAppend( + local.timingBreakdown, + { + name = LCase(local.tkey), + ms = arguments.execution[local.tkey], + pct = Round((arguments.execution[local.tkey] / arguments.execution.total) * 100) + } + ); + } + } + } + return local.timingBreakdown; + } + + function $debugParamsList(required struct params) { + local.paramsList = []; + for (local.pi in arguments.params) { + if (local.pi IS NOT "fieldnames" AND local.pi IS NOT "route" AND local.pi IS NOT "controller" AND local.pi IS NOT "action" AND local.pi IS NOT "key") { + if (IsSimpleValue(arguments.params[local.pi])) { + ArrayAppend( + local.paramsList, + {name = LCase(local.pi), value = arguments.params[local.pi], type = "string"} + ); + } else if (IsStruct(arguments.params[local.pi]) OR IsArray(arguments.params[local.pi])) { + ArrayAppend( + local.paramsList, + {name = LCase(local.pi), value = SerializeJSON(arguments.params[local.pi]), type = "json"} + ); + } + } + } + return local.paramsList; + } + - + - - - - - - - - - - + - - - - - - - - - - + diff --git a/vendor/wheels/public/migrator/command.cfm b/vendor/wheels/public/migrator/command.cfm index 64e0ac5352..2786b82c2c 100644 --- a/vendor/wheels/public/migrator/command.cfm +++ b/vendor/wheels/public/migrator/command.cfm @@ -11,118 +11,142 @@ param name="request.wheels.params.version"; * developer merely visits cannot auto-submit a command. */ -// ── Security: localhost only ──────────────────── -local.remoteAddr = cgi.REMOTE_ADDR; -local.isLocalhost = false; -try { - local.remoteInet = createObject("java", "java.net.InetAddress").getByName(local.remoteAddr); - local.isLocalhost = local.remoteInet.isLoopbackAddress(); -} catch (any e) { +function $migratorEnforceLocalhost() { + // ── Security: localhost only ──────────────────── + local.remoteAddr = cgi.REMOTE_ADDR; local.isLocalhost = false; -} -if (!local.isLocalhost) { - cfheader(statuscode=403); - cfcontent(type="text/plain", reset=true); - writeOutput("Migrator commands are restricted to localhost"); - abort; + try { + local.remoteInet = createObject("java", "java.net.InetAddress").getByName(local.remoteAddr); + local.isLocalhost = local.remoteInet.isLoopbackAddress(); + } catch (any e) { + local.isLocalhost = false; + } + if (!local.isLocalhost) { + cfheader(statuscode=403); + cfcontent(type="text/plain", reset=true); + writeOutput("Migrator commands are restricted to localhost"); + abort; + } } -// ── Security: X-Forwarded-For proxy bypass prevention ── -if (len(trim(cgi.HTTP_X_FORWARDED_FOR))) { - local.forwardedIps = listToArray(cgi.HTTP_X_FORWARDED_FOR); - for (local.ip in local.forwardedIps) { - try { - local.fwdInet = createObject("java", "java.net.InetAddress").getByName(trim(local.ip)); - if (!local.fwdInet.isLoopbackAddress()) { +function $migratorEnforceNoForwardedClients() { + // ── Security: X-Forwarded-For proxy bypass prevention ── + if (len(trim(cgi.HTTP_X_FORWARDED_FOR))) { + local.forwardedIps = listToArray(cgi.HTTP_X_FORWARDED_FOR); + for (local.ip in local.forwardedIps) { + try { + local.fwdInet = createObject("java", "java.net.InetAddress").getByName(trim(local.ip)); + if (!local.fwdInet.isLoopbackAddress()) { + cfheader(statuscode=403); + cfcontent(type="text/plain", reset=true); + writeOutput("Migrator commands are restricted to localhost"); + abort; + } + } catch (any e) { cfheader(statuscode=403); cfcontent(type="text/plain", reset=true); writeOutput("Migrator commands are restricted to localhost"); abort; } - } catch (any e) { - cfheader(statuscode=403); - cfcontent(type="text/plain", reset=true); - writeOutput("Migrator commands are restricted to localhost"); - abort; } } } -// ── Security: anti-CSRF token via custom request header ── -// The token is generated when the migrator GUI renders (../views/migrator.cfm) -// and must round-trip in the X-Wheels-Csrf-Token header. Cross-site pages -// cannot set custom headers without a CORS preflight (which this endpoint -// never approves), so auto-submitted GET/form requests are blocked. Fails -// closed when no token has been issued yet. -local.suppliedCsrfToken = ""; -local.requestHeaders = GetHTTPRequestData().headers; -if (structKeyExists(local.requestHeaders, "X-Wheels-Csrf-Token") && isSimpleValue(local.requestHeaders["X-Wheels-Csrf-Token"])) { - local.suppliedCsrfToken = local.requestHeaders["X-Wheels-Csrf-Token"]; -} -local.csrfTokenValid = false; -if ( - len(local.suppliedCsrfToken) - && structKeyExists(application, "wheels") - && structKeyExists(application.wheels, "$migratorCsrfToken") - && len(application.wheels.$migratorCsrfToken) -) { - // Constant-time comparison to prevent timing attacks - local.inputBytes = Hash(local.suppliedCsrfToken, "SHA-256").getBytes("UTF-8"); - local.expectedBytes = Hash(application.wheels.$migratorCsrfToken, "SHA-256").getBytes("UTF-8"); - local.csrfTokenValid = CreateObject("java", "java.security.MessageDigest").isEqual(local.inputBytes, local.expectedBytes); -} -if (!local.csrfTokenValid) { - cfheader(statuscode=403); - cfcontent(type="text/plain", reset=true); - writeOutput("Missing or invalid migrator CSRF token. Open /wheels/migrator and use the GUI buttons."); - abort; +function $migratorVerifyCsrfToken() { + // ── Security: anti-CSRF token via custom request header ── + // The token is generated when the migrator GUI renders (../views/migrator.cfm) + // and must round-trip in the X-Wheels-Csrf-Token header. Cross-site pages + // cannot set custom headers without a CORS preflight (which this endpoint + // never approves), so auto-submitted GET/form requests are blocked. Fails + // closed when no token has been issued yet. + local.suppliedCsrfToken = ""; + local.requestHeaders = GetHTTPRequestData().headers; + if (structKeyExists(local.requestHeaders, "X-Wheels-Csrf-Token") && isSimpleValue(local.requestHeaders["X-Wheels-Csrf-Token"])) { + local.suppliedCsrfToken = local.requestHeaders["X-Wheels-Csrf-Token"]; + } + local.csrfTokenValid = false; + if ( + len(local.suppliedCsrfToken) + && structKeyExists(application, "wheels") + && structKeyExists(application.wheels, "$migratorCsrfToken") + && len(application.wheels.$migratorCsrfToken) + ) { + // Constant-time comparison to prevent timing attacks + local.inputBytes = Hash(local.suppliedCsrfToken, "SHA-256").getBytes("UTF-8"); + local.expectedBytes = Hash(application.wheels.$migratorCsrfToken, "SHA-256").getBytes("UTF-8"); + local.csrfTokenValid = CreateObject("java", "java.security.MessageDigest").isEqual(local.inputBytes, local.expectedBytes); + } + if (!local.csrfTokenValid) { + cfheader(statuscode=403); + cfcontent(type="text/plain", reset=true); + writeOutput("Missing or invalid migrator CSRF token. Open /wheels/migrator and use the GUI buttons."); + abort; + } } -executeAction = StructKeyExists(request.wheels.params, "confirm") && request.wheels.params.confirm ? true : false; -missingMigFlag = StructKeyExists(request.wheels.params, "missingMigFlag") && request.wheels.params.missingMigFlag ? true : false; +function $migratorComputeResult() { + local.executeAction = StructKeyExists(request.wheels.params, "confirm") && request.wheels.params.confirm ? true : false; + local.missingMigFlag = StructKeyExists(request.wheels.params, "missingMigFlag") && request.wheels.params.missingMigFlag ? true : false; -message = ""; -result = ""; + local.message = ""; + local.result = ""; -// To actually perform a destructive action, we need ?confirm=1 in the URL -// So POST to /wheels/migrator/migrateto/[VERSION] will request confirmation of that action -if (executeAction) { - migrator = application.wheels.migrator; - switch (request.wheels.params.command) { - case "migrateTo": - result = migrator.migrateTo(request.wheels.params.version, missingMigFlag); - break; - case "migrateTolatest": - result = migrator.migrateToLatest(); - break; - case "undoMigration": - result = migrator.migrateTo(request.wheels.params.version); - break; - case "redoMigration": - result = migrator.redoMigration(request.wheels.params.version); - break; - case "migrateIndividual": - result = migrator.migrateIndividual(request.wheels.params.version); - break; - default: - } -} else { - switch (request.wheels.params.command) { - case "migrateTo": - message = "This will migrate the database schema to #request.wheels.params.version#"; - break; - case "migrateTolatest": - message = "This will migrate the database schema to the latest version"; - break; - case "redoMigration": - message = "This will redo the database migration at #request.wheels.params.version#"; - break; - case "migrateIndividual": - message = "This will run migration #request.wheels.params.version# individually (out of sequence)"; - break; - default: + // To actually perform a destructive action, we need ?confirm=1 in the URL + // So POST to /wheels/migrator/migrateto/[VERSION] will request confirmation of that action + if (local.executeAction) { + local.migrator = application.wheels.migrator; + switch (request.wheels.params.command) { + case "migrateTo": + local.result = local.migrator.migrateTo(request.wheels.params.version, local.missingMigFlag); + break; + case "migrateTolatest": + local.result = local.migrator.migrateToLatest(); + break; + case "undoMigration": + local.result = local.migrator.migrateTo(request.wheels.params.version); + break; + case "redoMigration": + local.result = local.migrator.redoMigration(request.wheels.params.version); + break; + case "migrateIndividual": + local.result = local.migrator.migrateIndividual(request.wheels.params.version); + break; + default: + } + } else { + switch (request.wheels.params.command) { + case "migrateTo": + local.message = "This will migrate the database schema to #request.wheels.params.version#"; + break; + case "migrateTolatest": + local.message = "This will migrate the database schema to the latest version"; + break; + case "redoMigration": + local.message = "This will redo the database migration at #request.wheels.params.version#"; + break; + case "migrateIndividual": + local.message = "This will run migration #request.wheels.params.version# individually (out of sequence)"; + break; + default: + } } + + return { + executeAction: local.executeAction, + missingMigFlag: local.missingMigFlag, + message: local.message, + result: local.result + }; } + +$migratorEnforceLocalhost(); +$migratorEnforceNoForwardedClients(); +$migratorVerifyCsrfToken(); +local.computed = $migratorComputeResult(); +executeAction = local.computed.executeAction; +missingMigFlag = local.computed.missingMigFlag; +message = local.computed.message; +result = local.computed.result; diff --git a/vendor/wheels/public/views/consoleeval.cfm b/vendor/wheels/public/views/consoleeval.cfm index 992561bc2d..d883e7c06f 100644 --- a/vendor/wheels/public/views/consoleeval.cfm +++ b/vendor/wheels/public/views/consoleeval.cfm @@ -11,158 +11,175 @@ cfheader(statuscode="200"); cfcontent(type="application/json"); -// ── Security: POST only (defense-in-depth) ───── -if (cgi.REQUEST_METHOD != "POST") { - cfheader(statuscode="405"); - writeOutput(serializeJSON({success: false, error: "Method not allowed. Use POST."})); - abort; +function $consoleEvalEnforcePostMethod() { + // ── Security: POST only (defense-in-depth) ───── + if (cgi.REQUEST_METHOD != "POST") { + cfheader(statuscode="405"); + writeOutput(serializeJSON({success: false, error: "Method not allowed. Use POST."})); + abort; + } } -// ── Security: localhost only ──────────────────── -local.remoteAddr = cgi.REMOTE_ADDR; -local.isLocalhost = false; -try { - local.remoteInet = createObject("java", "java.net.InetAddress").getByName(local.remoteAddr); - local.isLocalhost = local.remoteInet.isLoopbackAddress(); -} catch (any e) { +function $consoleEvalEnforceLocalhost() { + // ── Security: localhost only ──────────────────── + local.remoteAddr = cgi.REMOTE_ADDR; local.isLocalhost = false; -} -if (!local.isLocalhost) { - writeOutput(serializeJSON({success: false, error: "Console access restricted to localhost"})); - abort; + try { + local.remoteInet = createObject("java", "java.net.InetAddress").getByName(local.remoteAddr); + local.isLocalhost = local.remoteInet.isLoopbackAddress(); + } catch (any e) { + local.isLocalhost = false; + } + if (!local.isLocalhost) { + writeOutput(serializeJSON({success: false, error: "Console access restricted to localhost"})); + abort; + } } -// ── Security: X-Forwarded-For proxy bypass prevention ── -if (len(trim(cgi.HTTP_X_FORWARDED_FOR))) { - local.forwardedIps = listToArray(cgi.HTTP_X_FORWARDED_FOR); - for (local.ip in local.forwardedIps) { - try { - local.fwdInet = createObject("java", "java.net.InetAddress").getByName(trim(local.ip)); - if (!local.fwdInet.isLoopbackAddress()) { +function $consoleEvalEnforceNoForwardedClients() { + // ── Security: X-Forwarded-For proxy bypass prevention ── + if (len(trim(cgi.HTTP_X_FORWARDED_FOR))) { + local.forwardedIps = listToArray(cgi.HTTP_X_FORWARDED_FOR); + for (local.ip in local.forwardedIps) { + try { + local.fwdInet = createObject("java", "java.net.InetAddress").getByName(trim(local.ip)); + if (!local.fwdInet.isLoopbackAddress()) { + writeOutput(serializeJSON({success: false, error: "Console access restricted to localhost"})); + abort; + } + } catch (any e) { writeOutput(serializeJSON({success: false, error: "Console access restricted to localhost"})); abort; } - } catch (any e) { - writeOutput(serializeJSON({success: false, error: "Console access restricted to localhost"})); - abort; } } } -// ── Security: development mode only ───────────── -if ( - structKeyExists(application, "wheels") - && structKeyExists(application.wheels, "environment") - && application.wheels.environment != "development" -) { - writeOutput(serializeJSON({success: false, error: "Console only available in development mode. Current: " & application.wheels.environment})); - abort; -} - -// ── Security: Content-Type must be JSON ───────── -local.contentType = cgi.CONTENT_TYPE ?: ""; -if (!FindNoCase("application/json", local.contentType)) { - writeOutput(serializeJSON({success: false, error: "Content-Type must be application/json"})); - abort; +function $consoleEvalEnforceDevelopmentMode() { + // ── Security: development mode only ───────────── + if ( + structKeyExists(application, "wheels") + && structKeyExists(application.wheels, "environment") + && application.wheels.environment != "development" + ) { + writeOutput(serializeJSON({success: false, error: "Console only available in development mode. Current: " & application.wheels.environment})); + abort; + } } -// ── Parse request body ────────────────────────── -local.requestBody = toString(getHTTPRequestData().content); -if (!isJSON(local.requestBody)) { - writeOutput(serializeJSON({success: false, error: "Invalid request: expected JSON body"})); - abort; +function $consoleEvalEnforceJsonContentType() { + // ── Security: Content-Type must be JSON ───────── + local.contentType = cgi.CONTENT_TYPE ?: ""; + if (!FindNoCase("application/json", local.contentType)) { + writeOutput(serializeJSON({success: false, error: "Content-Type must be application/json"})); + abort; + } } -local.payload = deserializeJSON(local.requestBody); -local.expression = local.payload.expression ?: ""; -local.password = local.payload.password ?: ""; - -if (!len(trim(local.expression))) { - writeOutput(serializeJSON({success: false, error: "Empty expression"})); - abort; -} +function $consoleEvalParseBody() { + // ── Parse request body ────────────────────────── + local.requestBody = toString(getHTTPRequestData().content); + if (!isJSON(local.requestBody)) { + writeOutput(serializeJSON({success: false, error: "Invalid request: expected JSON body"})); + abort; + } -// ── Security: reload password (fail closed) ──── -if ( - !structKeyExists(application.wheels, "reloadPassword") - || !len(trim(application.wheels.reloadPassword)) -) { - writeOutput(serializeJSON({ - success: false, - error: "Console requires a reload password. Set WHEELS_RELOAD_PASSWORD in .env" - })); - abort; + local.payload = deserializeJSON(local.requestBody); + return { + expression: local.payload.expression ?: "", + password: local.payload.password ?: "" + }; } -// Rate limit: lock out IP after 5 failed attempts within 5 minutes -if (!structKeyExists(application, "$consoleRateLimit")) { - application.$consoleRateLimit = {}; -} -local.rateLimitKey = cgi.REMOTE_ADDR; -if (structKeyExists(application.$consoleRateLimit, local.rateLimitKey)) { - local.rl = application.$consoleRateLimit[local.rateLimitKey]; - if (local.rl.count >= 5 && dateDiff("n", local.rl.firstAttempt, now()) < 5) { - writeOutput(serializeJSON({success: false, error: "Too many failed attempts. Try again later."})); +function $consoleEvalRequireExpression(required expression) { + if (!len(trim(arguments.expression))) { + writeOutput(serializeJSON({success: false, error: "Empty expression"})); abort; } - if (dateDiff("n", local.rl.firstAttempt, now()) >= 5) { - structDelete(application.$consoleRateLimit, local.rateLimitKey); - } } -// Constant-time comparison to prevent timing attacks -local.inputBytes = Hash(local.password, "SHA-256").getBytes("UTF-8"); -local.expectedBytes = Hash(application.wheels.reloadPassword, "SHA-256").getBytes("UTF-8"); -if (!CreateObject("java", "java.security.MessageDigest").isEqual(local.inputBytes, local.expectedBytes)) { - if (!structKeyExists(application.$consoleRateLimit, local.rateLimitKey)) { - application.$consoleRateLimit[local.rateLimitKey] = {count: 0, firstAttempt: now()}; +function $consoleEvalRequireReloadPassword() { + // ── Security: reload password (fail closed) ──── + if ( + !structKeyExists(application.wheels, "reloadPassword") + || !len(trim(application.wheels.reloadPassword)) + ) { + writeOutput(serializeJSON({ + success: false, + error: "Console requires a reload password. Set WHEELS_RELOAD_PASSWORD in .env" + })); + abort; } - application.$consoleRateLimit[local.rateLimitKey].count++; - writeOutput(serializeJSON({ - success: false, - error: "Invalid reload password. Set WHEELS_RELOAD_PASSWORD in .env or pass --password to wheels console" - })); - abort; } -// ── Built-in commands ─────────────────────────── -if (local.expression == "__ping__") { - writeOutput(serializeJSON({ - success: true, - result: "pong", - type: "string", - output: "", - environment: application.wheels.environment ?: "unknown", - version: application.wheels.version ?: "unknown" - })); - abort; +function $consoleEvalCheckRateLimit(required rateLimitKey) { + // Rate limit: lock out IP after 5 failed attempts within 5 minutes + if (!structKeyExists(application, "$consoleRateLimit")) { + application.$consoleRateLimit = {}; + } + if (structKeyExists(application.$consoleRateLimit, arguments.rateLimitKey)) { + local.rl = application.$consoleRateLimit[arguments.rateLimitKey]; + if (local.rl.count >= 5 && dateDiff("n", local.rl.firstAttempt, now()) < 5) { + writeOutput(serializeJSON({success: false, error: "Too many failed attempts. Try again later."})); + abort; + } + if (dateDiff("n", local.rl.firstAttempt, now()) >= 5) { + structDelete(application.$consoleRateLimit, arguments.rateLimitKey); + } + } } -if (local.expression == "__env__") { - local.envInfo = { - environment: application.wheels.environment ?: "unknown", - version: application.wheels.version ?: "unknown", - datasource: application.wheels.dataSourceName ?: "unknown", - urlRewriting: application.wheels.URLRewriting ?: "unknown" - }; - writeOutput(serializeJSON({ - success: true, - result: serializeJSON(local.envInfo), - type: "struct", - output: "" - })); - abort; +function $consoleEvalVerifyPassword(required password, required rateLimitKey) { + // Constant-time comparison to prevent timing attacks + local.inputBytes = Hash(arguments.password, "SHA-256").getBytes("UTF-8"); + local.expectedBytes = Hash(application.wheels.reloadPassword, "SHA-256").getBytes("UTF-8"); + if (!CreateObject("java", "java.security.MessageDigest").isEqual(local.inputBytes, local.expectedBytes)) { + if (!structKeyExists(application.$consoleRateLimit, arguments.rateLimitKey)) { + application.$consoleRateLimit[arguments.rateLimitKey] = {count: 0, firstAttempt: now()}; + } + application.$consoleRateLimit[arguments.rateLimitKey].count++; + writeOutput(serializeJSON({ + success: false, + error: "Invalid reload password. Set WHEELS_RELOAD_PASSWORD in .env or pass --password to wheels console" + })); + abort; + } } -// ── Evaluate expression ───────────────────────── -local.response = {success: true, output: "", result: "", type: "void", error: ""}; +function $consoleEvalHandleBuiltInCommands(required expression) { + // ── Built-in commands ─────────────────────────── + if (arguments.expression == "__ping__") { + writeOutput(serializeJSON({ + success: true, + result: "pong", + type: "string", + output: "", + environment: application.wheels.environment ?: "unknown", + version: application.wheels.version ?: "unknown" + })); + abort; + } -try { - local.captured = ""; - savecontent variable="local.captured" { - local.evalResult = evaluate(local.expression); + if (arguments.expression == "__env__") { + local.envInfo = { + environment: application.wheels.environment ?: "unknown", + version: application.wheels.version ?: "unknown", + datasource: application.wheels.dataSourceName ?: "unknown", + urlRewriting: application.wheels.URLRewriting ?: "unknown" + }; + writeOutput(serializeJSON({ + success: true, + result: serializeJSON(local.envInfo), + type: "struct", + output: "" + })); + abort; } - local.response.output = local.captured; +} + +function $consoleEvalFormatResult(required evalResult, required response) { + local.evalResult = arguments.evalResult; + local.response = arguments.response; if (!isNull(local.evalResult)) { // Query objects (from findAll, etc.) @@ -246,14 +263,45 @@ try { } } } -} catch (any e) { - local.response.success = false; - local.response.error = e.message; - if (len(e.detail ?: "")) { - local.response.error &= " -- " & e.detail; +} + +function $consoleEvalEvaluate(required expression) { + // ── Evaluate expression ───────────────────────── + local.response = {success: true, output: "", result: "", type: "void", error: ""}; + + try { + local.captured = ""; + savecontent variable="local.captured" { + local.evalResult = evaluate(arguments.expression); + } + local.response.output = local.captured; + + $consoleEvalFormatResult(local.evalResult, local.response); + } catch (any e) { + local.response.success = false; + local.response.error = e.message; + if (len(e.detail ?: "")) { + local.response.error &= " -- " & e.detail; + } } + + return local.response; } -writeOutput(serializeJSON(local.response)); +// ── Request pipeline ───────────────────────────── +$consoleEvalEnforcePostMethod(); +$consoleEvalEnforceLocalhost(); +$consoleEvalEnforceNoForwardedClients(); +$consoleEvalEnforceDevelopmentMode(); +$consoleEvalEnforceJsonContentType(); +local.payload = $consoleEvalParseBody(); +local.expression = local.payload.expression; +local.password = local.payload.password; +$consoleEvalRequireExpression(local.expression); +$consoleEvalRequireReloadPassword(); +$consoleEvalCheckRateLimit(cgi.REMOTE_ADDR); +$consoleEvalVerifyPassword(local.password, cgi.REMOTE_ADDR); +$consoleEvalHandleBuiltInCommands(local.expression); +writeOutput(serializeJSON($consoleEvalEvaluate(local.expression))); abort; diff --git a/vendor/wheels/public/views/mcp.cfm b/vendor/wheels/public/views/mcp.cfm index b5445f429b..7f2b6de295 100644 --- a/vendor/wheels/public/views/mcp.cfm +++ b/vendor/wheels/public/views/mcp.cfm @@ -16,239 +16,255 @@ // a future release. See: // https://guides.wheels.dev/v4-0-0/command-line-tools/mcp-integration -// Log one-time deprecation warning per JVM -if (!structKeyExists(application, "mcpHttpDeprecationLogged")) { - try { - writeLog( - file="wheels_mcp", - type="warning", - text="The in-dev-server MCP endpoint at /wheels/mcp is deprecated. " - & "Use 'wheels mcp wheels' (LuCLI stdio MCP) instead. " - & "See https://guides.wheels.dev/v4-0-0/command-line-tools/mcp-integration" - ); - } catch (any ignored) { /* logging is best-effort */ } - application.mcpHttpDeprecationLogged = true; +function $mcpLogDeprecationWarning() { + // Log one-time deprecation warning per JVM + if (!structKeyExists(application, "mcpHttpDeprecationLogged")) { + try { + writeLog( + file="wheels_mcp", + type="warning", + text="The in-dev-server MCP endpoint at /wheels/mcp is deprecated. " + & "Use 'wheels mcp wheels' (LuCLI stdio MCP) instead. " + & "See https://guides.wheels.dev/v4-0-0/command-line-tools/mcp-integration" + ); + } catch (any ignored) { /* logging is best-effort */ } + application.mcpHttpDeprecationLogged = true; + } } -// ── Security: development mode only ───────────── -if ( - structKeyExists(application, "wheels") - && structKeyExists(application.wheels, "environment") - && application.wheels.environment != "development" -) { - cfheader(statusCode="403"); - cfheader(name="Content-Type", value="application/json"); - local.errorResponse = { - "jsonrpc": "2.0", - "error": { - "code": -32001, - "message": "MCP endpoint is only available in development mode" - } - }; - local.errorResponse["id"] = javaCast("null", ""); - writeOutput(serializeJSON(local.errorResponse)); - abort; +function $mcpEnforceDevelopmentMode() { + // ── Security: development mode only ───────────── + if ( + structKeyExists(application, "wheels") + && structKeyExists(application.wheels, "environment") + && application.wheels.environment != "development" + ) { + cfheader(statusCode="403"); + cfheader(name="Content-Type", value="application/json"); + local.errorResponse = { + "jsonrpc": "2.0", + "error": { + "code": -32001, + "message": "MCP endpoint is only available in development mode" + } + }; + local.errorResponse["id"] = javaCast("null", ""); + writeOutput(serializeJSON(local.errorResponse)); + abort; + } } -// ── Security: localhost only ──────────────────── -// Use InetAddress.isLoopbackAddress() instead of a literal-string list so -// every loopback form matches (all of 127.0.0.0/8, ::1, and IPv4-mapped IPv6 -// like ::ffff:127.0.0.1), failing closed when the address cannot be parsed. -local.remoteAddr = cgi.REMOTE_ADDR; -local.isLocalhost = false; -try { - local.isLocalhost = createObject("java", "java.net.InetAddress").getByName(local.remoteAddr).isLoopbackAddress(); -} catch (any e) { +function $mcpEnforceLocalhost() { + // ── Security: localhost only ──────────────────── + // Use InetAddress.isLoopbackAddress() instead of a literal-string list so + // every loopback form matches (all of 127.0.0.0/8, ::1, and IPv4-mapped IPv6 + // like ::ffff:127.0.0.1), failing closed when the address cannot be parsed. + local.remoteAddr = cgi.REMOTE_ADDR; local.isLocalhost = false; -} -if (!local.isLocalhost) { - cfheader(statusCode="403"); - cfheader(name="Content-Type", value="application/json"); - local.errorResponse = { - "jsonrpc": "2.0", - "error": { - "code": -32001, - "message": "MCP endpoint is restricted to localhost" - } - }; - local.errorResponse["id"] = javaCast("null", ""); - writeOutput(serializeJSON(local.errorResponse)); - abort; -} - -// Handle OPTIONS requests — CORS is unnecessary since endpoint is localhost-only -if (cgi.request_method == "OPTIONS") { - cfheader(statusCode="405"); - cfheader(name="Content-Type", value="application/json"); - writeOutput(serializeJSON({"error": "OPTIONS method not supported"})); - abort; -} - -// For POST requests that get routed as GET due to internal routing restrictions, -// check if there's form data in the body that indicates this is actually a JSON-RPC POST -local.actualMethod = cgi.request_method; -if (cgi.request_method == "GET") { - // Check if this is actually a POST request that was routed as GET - local.httpData = getHTTPRequestData(); - local.bodyContent = toString(local.httpData.content); - if (len(trim(local.bodyContent)) > 0) { - try { - local.testJson = deserializeJSON(local.bodyContent); - if (structKeyExists(local.testJson, "jsonrpc") && structKeyExists(local.testJson, "method")) { - // This looks like a JSON-RPC request sent via POST - local.actualMethod = "POST"; + try { + local.isLocalhost = createObject("java", "java.net.InetAddress").getByName(local.remoteAddr).isLoopbackAddress(); + } catch (any e) { + local.isLocalhost = false; + } + if (!local.isLocalhost) { + cfheader(statusCode="403"); + cfheader(name="Content-Type", value="application/json"); + local.errorResponse = { + "jsonrpc": "2.0", + "error": { + "code": -32001, + "message": "MCP endpoint is restricted to localhost" } - } catch (any e) { - // Not JSON, continue as GET - } + }; + local.errorResponse["id"] = javaCast("null", ""); + writeOutput(serializeJSON(local.errorResponse)); + abort; } } -try { - // Initialize or get session manager - if (!structKeyExists(application, "mcpSessionManager")) { - application.mcpSessionManager = createObject("component", "wheels.public.mcp.SessionManager").init(); +function $mcpRejectOptions() { + // Handle OPTIONS requests — CORS is unnecessary since endpoint is localhost-only + if (cgi.request_method == "OPTIONS") { + cfheader(statusCode="405"); + cfheader(name="Content-Type", value="application/json"); + writeOutput(serializeJSON({"error": "OPTIONS method not supported"})); + abort; } - local.sessionManager = application.mcpSessionManager; +} - // Initialize MCP server instance - if (!structKeyExists(application, "mcpServer")) { - application.mcpServer = createObject("component", "wheels.public.mcp.McpServer").init(); - } - local.mcpServer = application.mcpServer; - - // Handle GET requests (SSE support or query-based testing) - if (local.actualMethod == "GET") { - // Check if this is a query-based JSON-RPC request for testing - if (structKeyExists(url, "method") && url.method == "POST" && structKeyExists(url, "body")) { - // Decode the body and treat as POST - local.actualMethod = "POST"; - local.requestBody = urlDecode(url.body); - local.sessionId = structKeyExists(cgi, "http_mcp_session_id") ? cgi.http_mcp_session_id : local.sessionManager.createSession(); - } else { - // Check if client accepts SSE - local.acceptHeader = cgi.http_accept ?: ""; - if (find("text/event-stream", local.acceptHeader)) { - // Return SSE stream - cfheader(name="Content-Type", value="text/event-stream"); - cfheader(name="Cache-Control", value="no-cache"); - cfheader(name="Connection", value="keep-alive"); - - // Create or get session - local.sessionId = local.sessionManager.createSession(); - cfheader(name="Mcp-Session-Id", value=local.sessionId); - - // Send initial SSE message - writeOutput("data: " & serializeJSON({ - "type": "connection", - "sessionId": local.sessionId, - "status": "connected" - }) & chr(10) & chr(10)); - cfflush(); - abort; - } else { - // Return 405 Method Not Allowed for non-SSE GET requests - cfheader(statusCode="405"); - writeOutput("GET requests must accept text/event-stream"); - abort; +function $mcpHandleRequest() { + // For POST requests that get routed as GET due to internal routing restrictions, + // check if there's form data in the body that indicates this is actually a JSON-RPC POST + local.actualMethod = cgi.request_method; + if (cgi.request_method == "GET") { + // Check if this is actually a POST request that was routed as GET + local.httpData = getHTTPRequestData(); + local.bodyContent = toString(local.httpData.content); + if (len(trim(local.bodyContent)) > 0) { + try { + local.testJson = deserializeJSON(local.bodyContent); + if (structKeyExists(local.testJson, "jsonrpc") && structKeyExists(local.testJson, "method")) { + // This looks like a JSON-RPC request sent via POST + local.actualMethod = "POST"; + } + } catch (any e) { + // Not JSON, continue as GET } } } - // Handle POST requests (JSON-RPC messages) - if (local.actualMethod == "POST") { - // Get session ID from header or create new one (may already be set for query-based requests) - if (!structKeyExists(local, "sessionId")) { - local.sessionId = structKeyExists(cgi, "http_mcp_session_id") ? cgi.http_mcp_session_id : local.sessionManager.createSession(); + try { + // Initialize or get session manager + if (!structKeyExists(application, "mcpSessionManager")) { + application.mcpSessionManager = createObject("component", "wheels.public.mcp.SessionManager").init(); + } + local.sessionManager = application.mcpSessionManager; + + // Initialize MCP server instance + if (!structKeyExists(application, "mcpServer")) { + application.mcpServer = createObject("component", "wheels.public.mcp.McpServer").init(); } + local.mcpServer = application.mcpServer; - // Get request body (may have already been read for method detection or query params) - if (!structKeyExists(local, "requestBody")) { - if (structKeyExists(local, "bodyContent")) { - local.requestBody = local.bodyContent; + // Handle GET requests (SSE support or query-based testing) + if (local.actualMethod == "GET") { + // Check if this is a query-based JSON-RPC request for testing + if (structKeyExists(url, "method") && url.method == "POST" && structKeyExists(url, "body")) { + // Decode the body and treat as POST + local.actualMethod = "POST"; + local.requestBody = urlDecode(url.body); + local.sessionId = structKeyExists(cgi, "http_mcp_session_id") ? cgi.http_mcp_session_id : local.sessionManager.createSession(); } else { - local.httpData = getHTTPRequestData(); - local.requestBody = toString(local.httpData.content); + // Check if client accepts SSE + local.acceptHeader = cgi.http_accept ?: ""; + if (find("text/event-stream", local.acceptHeader)) { + // Return SSE stream + cfheader(name="Content-Type", value="text/event-stream"); + cfheader(name="Cache-Control", value="no-cache"); + cfheader(name="Connection", value="keep-alive"); + + // Create or get session + local.sessionId = local.sessionManager.createSession(); + cfheader(name="Mcp-Session-Id", value=local.sessionId); + + // Send initial SSE message + writeOutput("data: " & serializeJSON({ + "type": "connection", + "sessionId": local.sessionId, + "status": "connected" + }) & chr(10) & chr(10)); + cfflush(); + abort; + } else { + // Return 405 Method Not Allowed for non-SSE GET requests + cfheader(statusCode="405"); + writeOutput("GET requests must accept text/event-stream"); + abort; + } } } - if (len(trim(local.requestBody)) == 0) { - // Return 400 Bad Request for empty body - cfheader(statusCode="400"); - cfheader(name="Content-Type", value="application/json"); - local.errorResponse = { - "jsonrpc": "2.0", - "error": { - "code": -32600, - "message": "Invalid Request", - "data": "Request body is empty" + // Handle POST requests (JSON-RPC messages) + if (local.actualMethod == "POST") { + // Get session ID from header or create new one (may already be set for query-based requests) + if (!structKeyExists(local, "sessionId")) { + local.sessionId = structKeyExists(cgi, "http_mcp_session_id") ? cgi.http_mcp_session_id : local.sessionManager.createSession(); + } + + // Get request body (may have already been read for method detection or query params) + if (!structKeyExists(local, "requestBody")) { + if (structKeyExists(local, "bodyContent")) { + local.requestBody = local.bodyContent; + } else { + local.httpData = getHTTPRequestData(); + local.requestBody = toString(local.httpData.content); } - }; - local.errorResponse["id"] = javaCast("null", ""); - writeOutput(serializeJSON(local.errorResponse)); - abort; - } + } - // Parse JSON-RPC request - try { - local.jsonRpcRequest = deserializeJSON(local.requestBody); - } catch (any e) { - // Return 400 Bad Request for invalid JSON - cfheader(statusCode="400"); + if (len(trim(local.requestBody)) == 0) { + // Return 400 Bad Request for empty body + cfheader(statusCode="400"); + cfheader(name="Content-Type", value="application/json"); + local.errorResponse = { + "jsonrpc": "2.0", + "error": { + "code": -32600, + "message": "Invalid Request", + "data": "Request body is empty" + } + }; + local.errorResponse["id"] = javaCast("null", ""); + writeOutput(serializeJSON(local.errorResponse)); + abort; + } + + // Parse JSON-RPC request + try { + local.jsonRpcRequest = deserializeJSON(local.requestBody); + } catch (any e) { + // Return 400 Bad Request for invalid JSON + cfheader(statusCode="400"); + cfheader(name="Content-Type", value="application/json"); + local.errorResponse = { + "jsonrpc": "2.0", + "error": { + "code": -32700, + "message": "Parse error", + "data": "Request body is not valid JSON" + } + }; + local.errorResponse["id"] = javaCast("null", ""); + writeOutput(serializeJSON(local.errorResponse)); + abort; + } + + // Process the JSON-RPC request + local.response = local.mcpServer.handleRequest(local.jsonRpcRequest, local.sessionId); + + // Set response headers + cfheader(name="Mcp-Session-Id", value=local.sessionId); cfheader(name="Content-Type", value="application/json"); - local.errorResponse = { - "jsonrpc": "2.0", - "error": { - "code": -32700, - "message": "Parse error", - "data": "Request body is not valid JSON" - } - }; - local.errorResponse["id"] = javaCast("null", ""); - writeOutput(serializeJSON(local.errorResponse)); + cfheader(statusCode="200"); + + // Return JSON-RPC response + writeOutput(serializeJSON(local.response)); abort; } - // Process the JSON-RPC request - local.response = local.mcpServer.handleRequest(local.jsonRpcRequest, local.sessionId); - - // Set response headers - cfheader(name="Mcp-Session-Id", value=local.sessionId); + // Return 405 Method Not Allowed for other methods + cfheader(statusCode="405"); cfheader(name="Content-Type", value="application/json"); - cfheader(statusCode="200"); + writeOutput(serializeJSON({ + "error": "Only GET and POST methods are supported", + "supportedMethods": ["GET", "POST"] + })); - // Return JSON-RPC response - writeOutput(serializeJSON(local.response)); - abort; - } - - // Return 405 Method Not Allowed for other methods - cfheader(statusCode="405"); - cfheader(name="Content-Type", value="application/json"); - writeOutput(serializeJSON({ - "error": "Only GET and POST methods are supported", - "supportedMethods": ["GET", "POST"] - })); - -} catch (any e) { - try { - writeLog( - file="wheels_mcp", - type="error", - text="MCP error: " & e.message & " | Detail: " & (structKeyExists(e, "detail") ? e.detail : "") - ); - } catch (any logErr) { - // Fail silently if logging fails + } catch (any e) { + try { + writeLog( + file="wheels_mcp", + type="error", + text="MCP error: " & e.message & " | Detail: " & (structKeyExists(e, "detail") ? e.detail : "") + ); + } catch (any logErr) { + // Fail silently if logging fails + } + cfheader(statusCode="500"); + cfheader(name="Content-Type", value="application/json"); + writeOutput(serializeJSON({ + "jsonrpc": "2.0", + "error": { + "code": -32603, + "message": "Internal error" + }, + "id": javaCast("null", "") + })); } - cfheader(statusCode="500"); - cfheader(name="Content-Type", value="application/json"); - writeOutput(serializeJSON({ - "jsonrpc": "2.0", - "error": { - "code": -32603, - "message": "Internal error" - }, - "id": javaCast("null", "") - })); } - \ No newline at end of file + +$mcpLogDeprecationWarning(); +$mcpEnforceDevelopmentMode(); +$mcpEnforceLocalhost(); +$mcpRejectOptions(); +$mcpHandleRequest(); + diff --git a/vendor/wheels/public/views/plugins.cfm b/vendor/wheels/public/views/plugins.cfm index af2acbbfb8..bca0d2fd9c 100644 --- a/vendor/wheels/public/views/plugins.cfm +++ b/vendor/wheels/public/views/plugins.cfm @@ -2,13 +2,15 @@ // Check for JSON format request param name="request.wheels.params.format" default="html"; -if(!application.wheels.enablePluginsComponent) - throw(type="wheels.plugins", message="The Wheels Plugin component is disabled..."); +function $pluginsEnsureEnabled() { + if(!application.wheels.enablePluginsComponent) + throw(type="wheels.plugins", message="The Wheels Plugin component is disabled..."); +} -loadedPlugins = application.wheels.plugins; +function $pluginsRenderJson() { + loadedPlugins = application.wheels.plugins; -// If JSON format is requested, return JSON response -if (request.wheels.params.format == "json") { + // If JSON format is requested, return JSON response local.pluginsData = { "version": application.wheels.version, "timestamp": now(), @@ -64,6 +66,11 @@ if (request.wheels.params.format == "json") { abort; } +$pluginsEnsureEnabled(); + +if (request.wheels.params.format == "json") { + $pluginsRenderJson(); +} From e15fa596b0c96adb76a1750857c08a4c8a08a9d9 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 28 Aug 2026 17:14:05 -0700 Subject: [PATCH 2/3] refactor(test): extract type helpers from Assertion.equalize (46 to 19) Six private $-prefixed helpers for the simple-value, query, array, struct, function, and XML comparison branches; pass/fail semantics and failure messages unchanged (model suite 979/0 exercises every expect() through it). Signed-off-by: Peter Amiri --- vendor/wheels/wheelstest/system/Assertion.cfc | 207 +++++++++--------- 1 file changed, 108 insertions(+), 99 deletions(-) diff --git a/vendor/wheels/wheelstest/system/Assertion.cfc b/vendor/wheels/wheelstest/system/Assertion.cfc index bf7c8ffd7f..5148ffa02f 100755 --- a/vendor/wheels/wheelstest/system/Assertion.cfc +++ b/vendor/wheels/wheelstest/system/Assertion.cfc @@ -1262,12 +1262,8 @@ component { private boolean function equalize( any expected, any actual ){ // Null values - if ( isNull( arguments.expected ) && isNull( arguments.actual ) ) { - return true; - } - if ( isNull( arguments.expected ) || isNull( arguments.actual ) ) { - return false; + return isNull( arguments.expected ) && isNull( arguments.actual ); } // Numerics @@ -1279,14 +1275,7 @@ component { return true; } - // Simple values - if ( - isSimpleValue( arguments.actual ) && isSimpleValue( arguments.expected ) && arguments.actual eq arguments.expected - ) { - return true; - } - - // Two simple values that did not match above are unequal, and saying so here + // Simple values that did not match above are unequal, and saying so here // is what keeps them away from the `.equals()` fallback at the bottom of this // function. That fallback is meant for objects; on BoxLang a simple value // resolves `.equals()` to the DateTime member function and throws @@ -1294,126 +1283,146 @@ component { // reported as an ERROR carrying a cast message instead of a FAILURE reading // "Expected [2] but received [0]" (#3302). if ( isSimpleValue( arguments.actual ) && isSimpleValue( arguments.expected ) ) { - return false; + return $equalizeSimpleValues( arguments.expected, arguments.actual ); } // Queries if ( isQuery( arguments.actual ) && isQuery( arguments.expected ) ) { - // Check number of records - if ( arguments.actual.recordCount != arguments.expected.recordCount ) { - return false; - } - - // Get both column lists and sort them the same - var actualColumnList = listSort( arguments.actual.columnList, "textNoCase" ); - var expectedColumnList = listSort( arguments.expected.columnList, "textNoCase" ); - - // Check column lists - if ( actualColumnList != expectedColumnList ) { - return false; - } - - // Loop over each row - var i = 0; - while ( ++i <= arguments.actual.recordCount ) { - // Loop over each column - for ( var column in listToArray( actualColumnList ) ) { - // Compare each value - if ( arguments.actual[ column ][ i ] != arguments.expected[ column ][ i ] ) { - // At the first sign of trouble, bail! - return false; - } - } - } - - // We made it here so nothing looked wrong - return true; + return $equalizeQueries( arguments.expected, arguments.actual ); } // UDFs - if ( - isCustomFunction( arguments.actual ) && isCustomFunction( arguments.expected ) && - arguments.actual.toString() eq arguments.expected.toString() - ) { - return true; + if ( isCustomFunction( arguments.actual ) && isCustomFunction( arguments.expected ) ) { + return $equalizeFunctions( arguments.expected, arguments.actual ); } // XML - if ( - isXMLDoc( arguments.actual ) && isXMLDoc( arguments.expected ) && - toString( arguments.actual ) eq toString( arguments.expected ) - ) { - return true; + if ( isXMLDoc( arguments.actual ) && isXMLDoc( arguments.expected ) ) { + return $equalizeXml( arguments.expected, arguments.actual ); } // Arrays if ( isArray( arguments.actual ) && isArray( arguments.expected ) ) { - // Confirm both arrays are the same length - if ( arrayLen( arguments.actual ) neq arrayLen( arguments.expected ) ) { - return false; - } + return $equalizeArrays( arguments.expected, arguments.actual ); + } - for ( var i = 1; i lte arrayLen( arguments.actual ); i++ ) { - // check for both being defined - if ( arrayIsDefined( arguments.actual, i ) and arrayIsDefined( arguments.expected, i ) ) { - // check for both nulls - if ( isNull( arguments.actual[ i ] ) and isNull( arguments.expected[ i ] ) ) { - continue; - } - // check if one is null mismatch - if ( isNull( arguments.actual[ i ] ) OR isNull( arguments.expected[ i ] ) ) { - return false; - } - // And make sure they match - if ( !equalize( arguments.actual[ i ], arguments.expected[ i ] ) ) { - return false; - } - continue; - } - // check if both not defined, then continue to next element - if ( !arrayIsDefined( arguments.actual, i ) and !arrayIsDefined( arguments.expected, i ) ) { - continue; - } else { + // Structs / Object + if ( isStruct( arguments.actual ) && isStruct( arguments.expected ) ) { + return $equalizeStructs( arguments.expected, arguments.actual ); + } + + return arguments.actual.equals( arguments.expected ); + } + + private boolean function $equalizeSimpleValues( any expected, any actual ){ + // Both arguments are simple values (guaranteed by the caller) that did not + // match the numeric check above, so plain equality decides. + return arguments.actual eq arguments.expected; + } + + private boolean function $equalizeQueries( any expected, any actual ){ + // Check number of records + if ( arguments.actual.recordCount != arguments.expected.recordCount ) { + return false; + } + + // Get both column lists and sort them the same + var actualColumnList = listSort( arguments.actual.columnList, "textNoCase" ); + var expectedColumnList = listSort( arguments.expected.columnList, "textNoCase" ); + + // Check column lists + if ( actualColumnList != expectedColumnList ) { + return false; + } + + // Loop over each row + var i = 0; + while ( ++i <= arguments.actual.recordCount ) { + // Loop over each column + for ( var column in listToArray( actualColumnList ) ) { + // Compare each value + if ( arguments.actual[ column ][ i ] != arguments.expected[ column ][ i ] ) { + // At the first sign of trouble, bail! return false; } } - - // If we made it here, we couldn't find anything different - return true; } - // Structs / Object - if ( isStruct( arguments.actual ) && isStruct( arguments.expected ) ) { - var actualKeys = listSort( structKeyList( arguments.actual ), "textNoCase" ); - var expectedKeys = listSort( structKeyList( arguments.expected ), "textNoCase" ); - var key = ""; + // We made it here so nothing looked wrong + return true; + } - // Confirm both structs have the same keys - if ( actualKeys neq expectedKeys ) { - return false; - } + private boolean function $equalizeArrays( any expected, any actual ){ + // Confirm both arrays are the same length + if ( arrayLen( arguments.actual ) neq arrayLen( arguments.expected ) ) { + return false; + } - // Loop over each key - for ( key in arguments.actual ) { + for ( var i = 1; i lte arrayLen( arguments.actual ); i++ ) { + // check for both being defined + if ( arrayIsDefined( arguments.actual, i ) and arrayIsDefined( arguments.expected, i ) ) { // check for both nulls - if ( isNull( arguments.actual[ key ] ) and isNull( arguments.expected[ key ] ) ) { + if ( isNull( arguments.actual[ i ] ) and isNull( arguments.expected[ i ] ) ) { continue; } // check if one is null mismatch - if ( isNull( arguments.actual[ key ] ) OR isNull( arguments.expected[ key ] ) ) { + if ( isNull( arguments.actual[ i ] ) OR isNull( arguments.expected[ i ] ) ) { return false; } - // And make sure they match when actual values exist - if ( !equalize( arguments.actual[ key ], arguments.expected[ key ] ) ) { + // And make sure they match + if ( !equalize( arguments.actual[ i ], arguments.expected[ i ] ) ) { return false; } + continue; } + // check if both not defined, then continue to next element + if ( !arrayIsDefined( arguments.actual, i ) and !arrayIsDefined( arguments.expected, i ) ) { + continue; + } else { + return false; + } + } - // If we made it here, we couldn't find anything different - return true; + // If we made it here, we couldn't find anything different + return true; + } + + private boolean function $equalizeStructs( any expected, any actual ){ + var actualKeys = listSort( structKeyList( arguments.actual ), "textNoCase" ); + var expectedKeys = listSort( structKeyList( arguments.expected ), "textNoCase" ); + var key = ""; + + // Confirm both structs have the same keys + if ( actualKeys neq expectedKeys ) { + return false; } - return arguments.actual.equals( arguments.expected ); + // Loop over each key + for ( key in arguments.actual ) { + // check for both nulls + if ( isNull( arguments.actual[ key ] ) and isNull( arguments.expected[ key ] ) ) { + continue; + } + // check if one is null mismatch + if ( isNull( arguments.actual[ key ] ) OR isNull( arguments.expected[ key ] ) ) { + return false; + } + // And make sure they match when actual values exist + if ( !equalize( arguments.actual[ key ], arguments.expected[ key ] ) ) { + return false; + } + } + + // If we made it here, we couldn't find anything different + return true; + } + + private boolean function $equalizeFunctions( any expected, any actual ){ + return arguments.actual.toString() eq arguments.expected.toString(); + } + + private boolean function $equalizeXml( any expected, any actual ){ + return toString( arguments.actual ) eq toString( arguments.expected ); } /** From 81b203afab56e60477cbb933201b10a16f8996ce Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 28 Aug 2026 17:14:05 -0700 Subject: [PATCH 3/3] refactor(cli): extract reload/request helpers from the app-template onRequestStart (44 to 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven public $-prefixed helpers in the Application.cfc shipped by wheels new: content-only, debug-IP overrides, environment-switch detection, rate limiting, refusal recording, the fail-closed reload gate, and the restart path. The onboarding harness shows the identical 34/10/3 result with the original and the refactored template (the 10 failures are pre-existing environmental ones: migration empty-string-default hardening vs the harness fixture + sandbox denials). ReloadEnvironmentSwitchParitySpec: the two environment-switch source pins now accept both the inline shape and the delegated-helper shape (same guarantee — computed before the gate, gate skips on already-applied). Signed-off-by: Peter Amiri --- .../templates/app/public/Application.cfc | 162 +++++++++++------- .../cli/ReloadEnvironmentSwitchParitySpec.cfc | 4 +- 2 files changed, 98 insertions(+), 68 deletions(-) diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc index 978f33c3ce..b9e231b49a 100644 --- a/cli/lucli/templates/app/public/Application.cfc +++ b/cli/lucli/templates/app/public/Application.cfc @@ -225,12 +225,7 @@ component output="false" { public boolean function onRequestStart( string targetPage ) { - if(structKeyExists(url, "format") && listFindNoCase("junit,json,txt", url.format)) - { - application.contentOnly = true; - }else{ - application.contentOnly = false; - } + this.$setContentOnlyForFormat(); local.lockName = "reloadLock" & this.name; @@ -245,6 +240,39 @@ component output="false" { // Need to setup the wheels struct up here since it's used to store debugging info below if this is a reload request. application.wo.$initializeRequestScope(); + this.$applyDebugIpAccessOverrides(); + + local.environmentSwitchAlreadyApplied = this.$isEnvironmentSwitchAlreadyApplied(); + + local.reloadAuthorized = this.$authorizeReload(local.environmentSwitchAlreadyApplied); + if (local.reloadAuthorized) { + this.$restartAppRequest(local.lockName); + return false; + } + + // Run the rest of the request start code. + arguments.componentReference = "wheels.events.EventMethods"; + application.wo.$simpleLock( + name = local.lockName, + execute = "$runOnRequestStart", + executeArgs = arguments, + type = "readOnly", + timeout = 180 + ); + + return true; + } + + public void function $setContentOnlyForFormat() { + if(structKeyExists(url, "format") && listFindNoCase("junit,json,txt", url.format)) + { + application.contentOnly = true; + }else{ + application.contentOnly = false; + } + } + + public void function $applyDebugIpAccessOverrides() { // IP-based access to public Component/debug GUI (only if allowed in settings) if (!structKeyExists(application.wheels, "debugIPAccess")) { application.wheels.debugIPAccess.originalEnablePublicComponent = application.wheels.enablePublicComponent; @@ -286,7 +314,9 @@ component output="false" { application.wheels.showErrorInformation = application.wheels.debugIPAccess.originalShowErrorInformation; } } + } + public boolean function $isEnvironmentSwitchAlreadyApplied() { // Loop-break for URL environment switches (issue #3030): $buildRedirectUrl() // keeps ?reload=&password=... on the post-restart redirect so the // framework's switch code (vendor/wheels/events/onapplicationstart.cfc) can see @@ -296,12 +326,55 @@ component output="false" { // already active, skip the restart and serve the request normally. // Trade-off: ?reload= is a no-op — use ?reload=true for a // same-environment restart. - local.environmentSwitchAlreadyApplied = StructKeyExists(url, "reload") + return StructKeyExists(url, "reload") && !IsBoolean(url.reload) && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "environment") && application.wheels.environment == url.reload; + } + + public boolean function $reloadRateLimited(required string clientIp) { + // Same per-IP store and window as wheels/events/onapplicationstart.cfc, so + // warm-path and cold-start attempts count against one shared bucket. + if (!StructKeyExists(application, "$reloadRateLimit")) { + application.$reloadRateLimit = {}; + } + local.reloadRateLimited = false; + if (StructKeyExists(application.$reloadRateLimit, arguments.clientIp)) { + local.reloadRateLimitEntry = application.$reloadRateLimit[arguments.clientIp]; + if (local.reloadRateLimitEntry.count >= 5 && DateDiff("n", local.reloadRateLimitEntry.firstAttempt, Now()) < 5) { + local.reloadRateLimited = true; + } + if (DateDiff("n", local.reloadRateLimitEntry.firstAttempt, Now()) >= 5) { + StructDelete(application.$reloadRateLimit, arguments.clientIp); + } + } + return local.reloadRateLimited; + } + + public void function $recordReloadRefusalReason(required boolean reloadAuthorized) { + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!arguments.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } + } + public boolean function $authorizeReload(required boolean environmentSwitchAlreadyApplied) { // Reload application properly using applicationStop() if requested. // SECURITY (issue #3062): the gate FAILS CLOSED. A URL-based reload requires a // non-empty configured reloadPassword AND a matching password parameter — an @@ -311,25 +384,11 @@ component output="false" { // attempts are logged to wheels_security.log with the trusted client IP and // feed the same per-IP rate limit as the cold-start path (5 failed attempts // within 5 minutes locks the source out). - local.reloadRequested = StructKeyExists(url, "reload") && !local.environmentSwitchAlreadyApplied; + local.reloadRequested = StructKeyExists(url, "reload") && !arguments.environmentSwitchAlreadyApplied; local.reloadAuthorized = false; if (local.reloadRequested && StructKeyExists(application, "wheels") && StructKeyExists(application, "wo")) { - // Same per-IP store and window as wheels/events/onapplicationstart.cfc, so - // warm-path and cold-start attempts count against one shared bucket. local.reloadClientIp = application.wo.$trustedClientIp(); - if (!StructKeyExists(application, "$reloadRateLimit")) { - application.$reloadRateLimit = {}; - } - local.reloadRateLimited = false; - if (StructKeyExists(application.$reloadRateLimit, local.reloadClientIp)) { - local.reloadRateLimitEntry = application.$reloadRateLimit[local.reloadClientIp]; - if (local.reloadRateLimitEntry.count >= 5 && DateDiff("n", local.reloadRateLimitEntry.firstAttempt, Now()) < 5) { - local.reloadRateLimited = true; - } - if (DateDiff("n", local.reloadRateLimitEntry.firstAttempt, Now()) >= 5) { - StructDelete(application.$reloadRateLimit, local.reloadClientIp); - } - } + local.reloadRateLimited = this.$reloadRateLimited(local.reloadClientIp); if ( !local.reloadRateLimited && StructKeyExists(application.wheels, "reloadPassword") @@ -359,51 +418,22 @@ component output="false" { // Fail silently if logging fails } } - // Record WHY a requested reload did not fire so the framework's debug - // bar can render a development-only notice instead of a silent no-op - // (issue #3311). Recording is environment-agnostic — a request-scope - // flag, no output; the message text and the development-environment - // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm - // so wording can improve without template drift. Wrong-password and - // rate-limited attempts deliberately collapse into one generic reason - // so the notice adds no oracle on top of $secureCompare(). - if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { - local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") - && Len(application.wheels.reloadPassword); - if (!local.reloadPasswordConfigured) { - request.wheels.reloadRefusedReason = "emptyPassword"; - } else if (!StructKeyExists(url, "password")) { - request.wheels.reloadRefusedReason = "missingPasswordParam"; - } else { - request.wheels.reloadRefusedReason = "refused"; - } - } - } - if (local.reloadAuthorized) { - application.wo.$debugPoint("total,reload"); - if (StructKeyExists(url, "lock") && !url.lock) { - this.$handleRestartAppRequest(); - } else { - // Case-exact "Application" — see the matching comment in onSessionStart(). - // A lowercase reference turns every authorized reload into an HTTP 500 on - // Adobe CF + case-sensitive filesystems (issue #3053 follow-up). - local.executeArgs = {"componentReference" = "Application"}; - application.wo.$simpleLock(name = local.lockName, execute = "$handleRestartAppRequest", type = "exclusive", timeout = 180, executeArgs = local.executeArgs); - } - return false; + this.$recordReloadRefusalReason(local.reloadAuthorized); } + return local.reloadAuthorized; + } - // Run the rest of the request start code. - arguments.componentReference = "wheels.events.EventMethods"; - application.wo.$simpleLock( - name = local.lockName, - execute = "$runOnRequestStart", - executeArgs = arguments, - type = "readOnly", - timeout = 180 - ); - - return true; + public void function $restartAppRequest(required string lockName) { + application.wo.$debugPoint("total,reload"); + if (StructKeyExists(url, "lock") && !url.lock) { + this.$handleRestartAppRequest(); + } else { + // Case-exact "Application" — see the matching comment in onSessionStart(). + // A lowercase reference turns every authorized reload into an HTTP 500 on + // Adobe CF + case-sensitive filesystems (issue #3053 follow-up). + local.executeArgs = {"componentReference" = "Application"}; + application.wo.$simpleLock(name = arguments.lockName, execute = "$handleRestartAppRequest", type = "exclusive", timeout = 180, executeArgs = local.executeArgs); + } } public boolean function onRequest( string targetPage ) { diff --git a/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc b/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc index d658eeb1b9..ce7c159f92 100644 --- a/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc +++ b/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc @@ -137,7 +137,7 @@ component extends="wheels.WheelsTest" { var content = fileRead(absolute); expect( - reFind('local\.environmentSwitchAlreadyApplied\s*=\s*StructKeyExists\(url,\s*"reload"\)', content) > 0 + reFind('local\.environmentSwitchAlreadyApplied\s*=\s*(this\.\$isEnvironmentSwitchAlreadyApplied\(\)|StructKeyExists\(url,\s*"reload"\))', content) > 0 ).toBeTrue( relPath & " must compute environmentSwitchAlreadyApplied before the reload " & "gate (issue ##3030)." @@ -149,7 +149,7 @@ component extends="wheels.WheelsTest" { & "redirected request does not restart again (issue ##3030)." ); expect( - reFind("&&\s*!local\.environmentSwitchAlreadyApplied", content) > 0 + reFind("&&\s*!(local|arguments)\.environmentSwitchAlreadyApplied", content) > 0 ).toBeTrue( relPath & " must skip the applicationStop() gate when the requested " & "environment is already active — without this the preserved parameters "