Repository navigation
Wheels 4.1.2
Added
- Migrations can opt out of the per-step transaction with
this.useTransaction = false. The migrator then runsup()/down()without a transaction and records or removes the version only after the step succeeds, so a migration that touches a second datasource runs on Adobe ColdFusion, which rejects mixed datasources inside a transaction. When Adobe raises that error, the migrator's report now names the opt-out. The default per-step transaction is unchanged (#3772) - Added a
baseUrlsetting for a canonical host.set(baseUrl="https://your-host")inconfig/settings.cfmmakesurlFor(),linkTo(),redirectTo(onlyPath=false), and asset URLs build every absolute URL from that scheme/host/port instead of the incoming request — so emailed links and links generated in background jobs no longer depend on how the request arrived. Explicithost/protocol/portarguments still win, and leaving it unset keeps the historical request-derived behaviour. InproductionWheels logs a one-time reminder when it is unset. (#3838)
Changed
- The minimal "Application Error" page shown when Wheels fails to initialize now follows the app's
showErrorInformationsetting once startup has got far enough to set it. When it's on (every environment exceptproduction, by default), the page shows the root cause in full: type, message, detail, the event and the tag context, with Adobe CF's event-handler wrapper unwrapped. Behaviour change: when it's off, the page shows only the pointer towheels.logand no longer prints the exception message, so a production startup failure's text no longer reaches the browser. SetshowErrorInformationtotrueto see it on the page. The full failure is still written towheels.login every environment, and that log line now carries the whole tag context (up to 20 frames), not only the first frame. A failure before the settings load still shows the bare message, as before. Every value is HTML-encoded. Existing apps must copy the change into their ownpublic/Application.cfc: replace$renderMinimalError()and$logStartupFailure(), replace$startupFailureFrame()with$startupFailureFrames(), and add$startupFailureShowDetail(),$startupFailureDetailHtml()and$startupFailureRoot()from the currentwheels newtemplate (#3671) set(baseUrl=...)is now parsed once when the application starts, so a malformed value stops the application from starting withWheels.IncorrectConfigurationinstead of failing the first absolute URL a request builds. The parser also rejects a query (?), fragment (#), user info (@), a backslash, an unbracketed IPv6 host, an invalid host character and a port outside 1-65535, and accepts a bracketed IPv6 host (https://[::1]:8443). New apps fromwheels newget a commentedset(baseUrl=...)line inconfig/settings.cfm(#3842)wheels generate auth: the generatedSessions.delete()(logout) now ends the session only for a POST (the logout form, which sends_method=delete) or a realDELETErequest. AGETorHEAD, which the default wildcard route also maps to/sessions/delete, redirects to the login page without logging out. Apps generated with an earlier version should add the same guard at the top ofdelete()inapp/controllers/Sessions.cfc: (#3819)if (!isPost() && !isDelete()) { redirectTo(route="login"); return; }- The migrator dev-tool endpoints and the GUI pages that issue their anti-CSRF token now require the request's
Hostheader to name the local machine, in addition to the existing loopback-socket and no-forwarded-client checks.localhost, any*.localhostname (RFC 6761),127.0.0.0/8, and[::1]are accepted by default. If you reach the migrator under a custom local hostname (for examplemyapp.testvia/etc/hosts, or a Valet/Herd-style*.test), it now returns403until you allow it withset(devToolsAllowedHosts="myapp.test")(comma-delimited, empty by default) — the same one setting the dispatch-level dev-tools gate uses. The403body states this fix. (#3840) $prependUrl(used byurlFor(onlyPath=false)and asset URLs) still derives an absolute URL'shttp/httpsscheme from the client-controlledX-Forwarded-Protoheader as before, so an app behind a TLS-terminating proxy keeps itshttps://URLs. When the header is the only reason forhttps://and the app has not setset(trustProxyHeaders=true), Wheels now logs a one-time warning towheels.log. Wheels 4.2 will requiretrustProxyHeaders=trueto honourX-Forwarded-Protofor the scheme and otherwise ignore it (matchingisSecure()); apps behind a trusted reverse proxy should settrustProxyHeaders=truenow. The real socket TLS state is unaffected (#3835)
Fixed
- The
apt/dnfwheelslauncher no longer uses a pre-setJAVA_HOMEthat points at a JDK older than 21. It now checks the version, printswheels: ignoring JAVA_HOME=<path> (Java N); Wheels needs Java 21+to stderr, and uses the Java 21 the package installed. Previously every command exceptwheels --versioncrashed withUnsupportedClassVersionErroron machines, including GitHub's Ubuntu runners, that exportJAVA_HOMEat Java 17. The fallback probes also skip an olderdefault-javaor alternativesjava. On 4.1.1, rununset JAVA_HOMEor pointJAVA_HOMEat a Java 21+ install as a workaround (Refs #3728) - On Adobe ColdFusion with Oracle, reading a table's column metadata no longer leaves JDBC statements open. Every
cfdbinfo(type="columns")call there retained about seven statements, with their row buffers, on the pooled connection for the connection's lifetime (enough to exhaust the heap of a long test run).$dbinfo(type="columns")now reads the same fields from the Oracle data dictionary (ALL_TAB_COLUMNSin the current schema, plus the primary-key constraint) with one ordinary query. A table the dictionary can't see in the current schema (reached through a synonym, or owned by another schema) still goes throughcfdbinfo, as before. Other engines and databases are unchanged (#3732) wheels testcan run a single spec file.--filter/--directoryaccept a spec file's dotted path (tests.specs.models.UserSpec) or its bare name (UserSpec), which is looked up anywhere undertests/specs/. A name that matches more than one file stops with the list of dotted paths instead of guessing. The app and core test runners run adirectory=that names a spec file as that one bundle, where it used to discover 0 bundles, so the same works when calling the runner URL directly and withbash tools/test-local.sh <area>/<Spec>. A run that discovers no bundles no longer prints a green0 passedline above its failure (#3759)- On BoxLang, a reload (
?reload=true&password=…or the CLI'sX-Wheels-Reload-Passwordheader) no longer stalls for the reload lock's timeout (180 s per wave, 360 s and more seen) while the application has active sessions, and each session'sonSessionEndhandler now runs during the reload instead of being skipped with a lock-timeout error. BoxLang ends sessions on worker threads insideapplicationStop(), while the reload request holds the exclusive reload lock;$simpleLock()now records the restart in the application scope, and a session end that finds that marker runs without waiting for the lock the parked restart holds. The fix is in the framework: existing apps need no change topublic/Application.cfc(#3770) wheels test --filter=<SpecName>no longer misnames a spec reached through a symlink into a sibling folder whose name starts with the spec root's (such astests/specsOld): it used to produce a wrong dotted path liketests.specs.ld.FooSpecand run 0 bundles. A spec folder reachable twice through a symlink inside the spec root is also no longer reported as an ambiguous match with two identical paths (#3800)- The CLI's RustCFML backend now downloads the engine build the framework is tested against (
v0.693.0, fromtools/rustcfml/ENGINE_VERSION) instead of the stalev0.637.0pin inRustCFMLEngine.cfc. The daily engine auto-bump now moves both pins together, and a CLI spec fails if they ever differ (#3812) - On an Intel Mac,
wheels start --engine=rustcfmlandwheels engines rustcfml install|startnow say that RustCFML publishes no macOS Intel (x86_64) build and to use the default engine (wheels start), instead of failing on a 404 download (#3815) processRequest()no longer changes the cached controller class's CSRF settings. Itscsrfoption (defaultignore) now applies only to the controller instance it builds, so real requests to a controller that a spec exercised withprocessRequest()keep their configured CSRF protection instead of skipping it until the next reload (#3843)- The per-request query cache now keys on the datasource a query actually runs against, after tenant resolution. An app that switches tenant (or datasource) within one request no longer receives another datasource's cached
findAll()/count()result for an identical finder call (#3844) sendFile(file="reports/2026/q1.zip")finds the file under thefilePathfolder on disk (public/files) again when the app runs in a subdirectory whose URL rewrites fold/public/out of the URL, or that setsset(subpath=...). The folder was built fromrootPath, the app's URL path, so in those layouts it pointed one level abovepublic/andsendFile()threwWheels.FileNotFound. It is now built from the web root's folder on disk. Root-anchored and mapping paths, full disk paths and an absolutedirectorybehave as before (#3852)- CLI commands that fail now exit non-zero, and the MCP tools report them as errors (
isError), instead of printing a message and reporting success. This coverswheels generaterefusals (the file already exists, an invalid name, an unknown property or test type, a route that exists, which now exits 1) and missing arguments, including a barewheels generate;wheels create,wheels destroy,wheels dbandwheels upgradewith no arguments;wheels migrate pretend|forgetwithout a version;wheels doctorwith status CRITICAL;wheels analyzeoutside a project;wheels destroyof a name that was never generated (it also no longer writes a drop-table migration);wheels docswith an unknown action andwheels docs statusoutside a project; a deploy config whoseserverslists no hosts; andwheels start --engine=rustcfmlwhen the port is already in use or the server exits right after starting. Error messages are printed once instead of twice. Scripts that relied on these commands exiting 0 after printing usage now need the arguments they were missing. Each failure message is complete on its own (usage line, doctor's issues), because MCP clients receive only the message. (#3863) wheels coverageon a warm dev server now also counts controller and modelconfig()methods. Wheels runsconfig()once per class and caches the result, so on a server that had already loaded them those methods never ran during the coverage run. Coverage mode now drops the cached classes so they are rebuilt from the instrumented code. (#3864)wheels coverageno longer reports 0% when the dev server has already served the app. With Lucee's defaultinspectTemplate=oncethe server kept running the code it had compiled before coverage instrumentedapp/, so no counter ever ran. Coverage mode now clears Lucee's compiled-page pool before the suite runs. The clear is server-wide, so other apps on the same dev server recompile on their next request. (#3859)dateField()now emits a bareyyyy-mm-ddvalue for its<input type="date">. Bound to adate/datetimeproperty that carries a time component (for example adatetimecolumn), it previously rendered the full value (e.g.2026-10-01 00:00:00), which browsers cannot display in a date control, so the field showed blank. An already-bare date, an explicit non-date value, and an empty value are left unchanged. (#3856)wheels docs fetchnow verifies the downloaded docs bundle against the SHA-512 checksum published next to it on the release before unpacking, and exits non-zero with a clear message when the checksum is missing or does not match, or the download or unpack fails, leaving any existing install in place instead of a half-unpacked docs directory (#3823)- The local docs mirror that
wheels docs fetchputs inpublic/wheels-docs/(about 300 MB) is no longer committed or shipped by accident. New apps list it in.gitignore,wheels deploy initlists it in.dockerignore, and the mirror now carries its own.gitignoreso existing apps keep it out of git too. In an existing app's.dockerignore, addpublic/wheels-docsyourself. If the mirror was already committed, untrack it withgit rm -r --cached public/wheels-docs. (#3855) wheels docs fetchno longer deletes apublic/wheels-docsfolder the app keeps for itself, and editing the app's docs mirror no longer changes the shared docs cache under~/.wheels/docs/<version>/. The mirror is now a real copy instead of hardlinks, built beside the target and renamed into place. It is replaced only when it is a docs mirror (it holds the bundle'smanifest.json) and only when missing or out of date. Apublic/wheels-docswithoutmanifest.jsonis left alone with a message saying so. If the mirror can't be copied or swapped in, the command now exits non-zero instead of reporting success. This matches the Linux package wrapper (#3821) (#3848)Wheels.TableNotFound,Wheels.DataSourceNotFound, andWheels.ColumnNotFounderror pages now return HTTP 500 instead of 404. These signal a misconfigured or unmigrated deploy (a server fault), so a 404 hid them from monitoring and made an unmigrated deploy look like missing pages. Genuine client not-found errors (record, route, view, action) still return 404. (#3862)- A missing or invalid CSRF token (
Wheels.InvalidAuthenticityToken) error page now returns HTTP 403 instead of 500, so a forged or expired-form POST is not counted as a server error. (#3862) wheels generate app <name> --dry-run(and the MCPgeneratetool withtype=appanddry-run) no longer creates the project. It printed "Dry run — nothing will be written" and then wrote the whole app, including avendor/wheelscopy and a.envwith generated passwords. It now lists the folder it would create and writes nothing. (#3858)- The documented example job now runs. It called
sendEmail()(and the background-jobs guide also calledmodel()), butwheels.Jobextends nothing, so neither was in scope and the job failed withNo matching function [SENDEMAIL] found.wheels.Jobnow has amodel()that delegates toapplication.wo.model(), somodel("User")works insideperform().sendEmail()stays a controller function: the example job in theapp/jobs/README.mdscaffold, theJob.cfcheader, the consumerCLAUDE.mdand the background-jobs guide now send mail through a mailer (new app.mailers.UserMailer().sendWelcome(user)). The scaffold README also drops its "Requirements" section, which told users to generate acreate_wheels_jobs_tablemigration (an empty TODO migration). Thewheels_jobstable is created automatically on first use (#3860) - The Linux
.deb/.rpmwheelscommand no longer deletes and re-copiespublic/wheels-docson every run from an app root, which wiped anything you had put there, briefly removed the docs assets from a running dev server, and hardlinked the copy so editing it changed the shared~/.wheels/docscache. It now copies the offline docs only when the mirror is missing or itsmanifest.jsonno longer matches the installed docs (so existing mirrors still refresh on upgrade), leaves apublic/wheels-docswithout amanifest.jsonalone, and warns instead of aborting your command if the copy fails (#3821) - The MCP tools (
wheels mcp wheels) no longer return ANSI colour codes in their text. CLI colour now appears only on an interactive terminal: it is dropped under the MCP server, when output is piped or redirected, and whenNO_COLORis set. (#3869) - Error pages now treat 404 as an allow-list of the client-triggerable not-found types (
Wheels.RouteNotFound,Wheels.RecordNotFound,Wheels.ViewNotFound,Wheels.FileNotFoundfromsendFile(),Wheels.ActionNotAllowed,Wheels.ActionParameterMissing) rather than mapping anyWheels.*NotFoundto 404. Every other*NotFound— a missing package, model, method, filter, association, Vite asset/manifest, and any future type — is a server-side config/code fault and now returns HTTP 500 so monitoring sees it, instead of masquerading as a 404. (This generalises theTable/DataSource/Column→ 500 fix.) (#3872) - An unsupported requested format (
Wheels.FormatNotAcceptable, e.g. a.ext/?format=the action cannot answer) error page now returns HTTP 406 Not Acceptable instead of 500. (#3872) wheels packages add --offline(andWHEELS_OFFLINE=1) no longer downloads the package tarball. Offline mode already refused the registry index and manifest fetches, but with a cached manifest the install still went to the network; it now fails with the offline message before any download (#3867)wheels packages addandupdatenow install the stable release instead of an equal pre-release (e.g.1.2.0over1.2.0-rc.1), an exact pin such as@1.2.0no longer matches1.2.0-rc.1, and pre-release versions are only considered when the pin names one (@1.2.0-rc.1); the CLI's SemVer comparison now orders pre-release labels per SemVer 2.0.0, andupdateno longer downgrades an installed pre-release that is newer than the latest release (#3829)reload()no longer truncates fractional values: it used to cast every numeric value that fit in 32 bits to an integer, so a decimal column holding149.25reloaded as149(and-3.75as-3). The object then saw the column as changed, and the nextsave()wrote the truncated value back to the database, even when only another field was edited. Only integer columns holding a whole number are normalized now. (#3818)- The CLI's RustCFML backend now verifies the engine binary against a pinned sha256 for its release asset before using it: a download that doesn't match is deleted and refused with
Wheels.RustCFML.ChecksumMismatch, a cached binary is re-checked instead of trusted, and the binary is only renamed into place once verified. A failed download now reportsWheels.RustCFML.InstallFailedwith its URL instead of a "Can't cast Complex Object Type Struct to String" error. The pins live intools/rustcfml/ENGINE_SHA256(mirrored inRustCFMLEngine.cfc), and the daily engine auto-bump refreshes them from the release's published digests (#3824) wheels generate scaffoldwith abooleanproperty, such aspublished:boolean, no longer generates specs that fail on SQLite with "Published is not a number". The generated test data usedtrue; a migration's boolean column isINTEGERon SQLite, so the model's automatic numericality validation rejected it. The generated specs now use1, which that validation accepts and whichBIT/BOOLEANcolumns on the other databases store as true. In specs generated with an earlier version, change"published": trueto"published": 1(#3868)wheels generate scaffold: a key that can't belong to the model, such asGET /posts/abcfor an integer primary key, now returns 404 instead of a 500Wheels.InvalidValueerror. The generatedrequireRecord()filter treatsWheels.InvalidValuefrom itsfindByKey()lookup as a missing record and rethrows any other error. The framework's check on typed key values is unchanged. Controllers generated with an earlier version should wrap the lookup in theirrequireRecord()the same way; the current version is inapp/snippets/CRUDContent.txtof a newwheels newapp (#3868)wheels generate scaffold: a request for a record that doesn't exist (GET /posts/999999) now returns 404 in production too. The generatedrequireRecord()filter threwWheels.RecordNotFound, which only the development error page turns into a 404, so production served its generic 500 page. The filter now calls$throwErrorOrShow404Page(), which sets 404 and shows the error in development orapp/events/onmissingtemplate.cfmin production. Controllers generated with an earlier version should replace theThrow(call in theirrequireRecord()with$throwErrorOrShow404Page((same arguments), and apps that keepapp/snippets/CRUDContent.txtshould make the same change there so later scaffolds pick it up (#3868)sendEmail(file="reports/q3.txt")attaches the file from thefilePathfolder on disk (public/files), as the argument docs say. Only a bare file name was resolved there; a relative path with a subfolder went tocfmailparamunchanged, which looked for it next to the framework's own templates and failed with "the file doesn't exist". Relative attachments now resolve the same way assendFile()'s files; absolute paths and URLs are passed through as before (#3852) (#3857)wheels new: the scaffolded session cookie is nowSecurewhenever the request that creates the session arrives over HTTPS, directly or through a TLS-terminating proxy that sendsX-Forwarded-Proto: https, as well as forWHEELS_ENV=production. It used to depend onWHEELS_ENV=productionalone. An app switched to production throughconfig/environment.cfm(the scaffolded.envstill saysdevelopment) therefore issued its session cookie withoutSecure. Apps created with 4.0.6-4.1.1 should updatethis.sessionCookie.secureinpublic/Application.cfcto: (#3830)(or setsecure: (structKeyExists(variables, "currentEnv") && currentEnv == "production") || (IsBoolean(cgi.server_port_secure) && cgi.server_port_secure) || cgi.https == "on" || cgi.http_x_forwarded_proto == "https"
this.sessionCookie.secure = true;inconfig/app.cfmwhen the site is HTTPS-only).wheels generate authalso adds a spec that aGETto the logout action does not end the session.- On SQLite and H2, boolean columns created by migrations (
t.boolean(),addColumn(columnType="boolean")) are now real boolean columns on the model. SQLite created them asINTEGERand H2 asTINYINT(1), so the model treated them as integers: automatic validation rejectedtrueand"true"with "is not a number", and a validatedsave()failed. The migrators now emitBOOLEAN, which both adapters read as a boolean, sotrue,"true"and"false"pass automatic validation and save. This applies to new columns only. ExistingINTEGER/TINYINTboolean columns keep the previous behaviour until a migration converts them, which keeps the stored 1/0 values (on SQLite it rebuilds the table):changeColumn(table="posts", columnName="published", columnType="boolean");. The new definition replaces the old one, so passdefaultandallowNullagain if the column had them (#3870) - After a failed
wheels start(for example a port that is already in use), the nextwheels startno longer refuses with "registered to a different project: ". The failed start left a registration folder that names no project; it is now treated as left over and cleaned.wheels startalso exits non-zero when it refuses to run, both outside a Wheels project and when the server name is registered to another project, so scripts and CI can tell. (#3861) wheels startcleans a leftover registration only when it has exactly the shape a failed start leaves (LuCLI's config file and nothing else). A stopped server registration of another project with the same name but no recorded project path (from plain LuCLI or before 4.0) is no longer wiped;wheels startrefuses and points to--forceinstead. (#3865)- An invalid
transactionmode (e.g. a typo such assave(transaction="commti")) no longer leaves the connection marked as having an open transaction. The mode was rejected only after that marker was set, so if the app caught the error, every later save/update/delete on the same connection in that request silently ran with no transaction.transaction="rollback"then persisted its changes. The mode is now validated before any transaction state changes. (#3817) - A request for a format the action does not provide now answers
406 Not Acceptableinstead of200with an empty body. On a controller that onlyprovides()html,GET /posts/2.json,/posts/2.xmlor/posts/new.jsonused to return an empty200. When theformatparam (the route's.[format]segment or?format=) names a format outside the action'sprovides()/onlyProvides()list, or a providedjson/xmlformat the action never renders (no template and norenderWith()call), and the action rendered nothing itself, the automatic render now throwsWheels.FormatNotAcceptablewith status 406 in development and returns a plain-text406 Not Acceptableresponse whenshowErrorInformationis off. Html requests, formats the action renders,renderWith()(which still falls back to the html view) and formats negotiated from theAcceptheader are unchanged (#3866) wheels upgrade applyno longer silently downgrades the framework when the app'svendor/wheels/is newer than the CLI's bundled copy (for example, an app created with a newer CLI or awheels-besnapshot). It now refuses before changing anything, names both versions, and suggests installing a newer CLI; pass the new--allow-downgradeflag to install the older framework anyway. The summary now saysupgraded,downgradedorreinstalledto match what actually happened (#3831)wheels upgrade checkwithout--to=now exits non-zero (Wheels.UpgradeCheckFailed) when the latest Wheels release can't be looked up, for example offline or when GitHub rate-limits the request. It used to print a warning and exit 0 without scanning anything, so a CI gate passed and--strictwas bypassed. The message names the reason and suggests--to=<version>;--format=jsonprints a{"success": false, "error": ...}document before exiting non-zero (#3827)wheels upgrade checkwithout--to=can reach GitHub again. The latest-release lookup was sent over the CLI's local dev-server transport, which speaks plain HTTP, so it could not reachhttps://api.github.comand every check failed withCould not determine the latest Wheels version from GitHub. It now uses the CLI's HTTPS client; calls to the local dev server are unchanged (#3854)wheels upgrade check --offline(andWHEELS_OFFLINE=1) no longer calls GitHub to look up the latest release.upgrade checknow accepts--offline, and the HTTP client thatwheels packagesandupgrade checkuse refuses requests while offline mode is on, so without--to=<version>the check fails with the offline message and asks for a target version instead of reaching the network. With--toit scans as before (#3871)wheels deployandwheels deploy setupnow build and push the image before deploying it, as the guides describe. They log in to the registry on this machine and on every host (the password from.kamal/secretstravels only on stdin), rundocker buildx build --push, then pull on the hosts. Pass--skip-pushwhen the image for that version is already pushed. If no registry password resolves, the login is skipped with a warning (#3874)- kamal-proxy now receives the proxy
host(--host) andssl(--tls) settings and the health-check interval, and the health-check interval and timeout are sent as durations (3s); TLS and host-based routing were silently dropped before (#3874) wheels deploy rollbacksends the proxy cutover only to roles that run kamal-proxy, and stops the other versions on every host, so a rollback no longer fails on worker hosts or leaves old and restored containers running side by side (#3874)--destination=<name>now fails whenconfig/deploy.<name>.ymldoes not exist, instead of silently deploying the base configuration (#3874)wheels destroy <Name> --forceandwheels destroy model <Name> --forceno longer generate a drop-table migration when there was no model to remove. That migration dropped a table that was never created, so the nextwheels migrate latestfailed.- Package tarballs built on macOS, which carry
._*AppleDouble metadata files, now install withwheels packages add. Those metadata files are skipped and never copied intovendor/(#3875) wheels packages updateandwheels packages add --forcekeep the installed package when moving the verified new version intovendor/fails, for example on a full disk or a permissions error. The installed copy is renamed aside, restored if the move fails, and removed once the new copy is in place (#3826)whereBetween()on a date column no longer fails on Oracle (ORA-01843); each bound is now handled like awhere()comparison, typed by the column (#3877)
Security
- The CLI can now prove its dev server with an application-level challenge, as well as OS socket introspection.
wheels startgives each server a per-start token, readable only by the starting OS user, in its registry directory (<LUCLI_HOME>/servers/<name>/wheels-cli.token). Before a command sends anything secret, the CLI checks on that same connection that the server can answer an HMAC challenge bound to the connection's addresses and ports. That lets state-changing commands work where/proc,lsofandnetstatcan't see the listener. A wrong answer fails closed. A framework without the challenge is verified through the OS as before. No token is issued on Windows, or when the registry directory is group- or world-writable. Port-forwarded setups (docker-proxy,ssh -L) can't pass the check, just as OS introspection can't prove them (#3769) - With
set(csrfStore="cookie"), the authenticity token is now compared exactly and in constant time ($secureCompare()), instead of with CFML==, which ignores case and stops at the first differing character. The default session store (CsrfVerifyToken()) is unchanged. (#3825) - The password-less
/wheelsdevelopment endpoints (the GUI pages, docs viewer, test runners, and the deprecated HTTP MCP) now require the request'sHostheader to name the local machine, and reject a non-loopbackX-Forwarded-Forhop, before any handler runs. This is a Host-level gate at the public-component dispatch: it does not check the socket address, so a Wheels dev server reached through a Docker port mapping (where the socket source is the bridge gateway, not loopback) keeps working.localhost, any*.localhost,127.0.0.0/8, and[::1]are accepted by default; add other local names withset(devToolsAllowedHosts="my.host"). The mutating endpoints keep their existing loopback-socket and CSRF/password checks on top. These endpoints remain blocked entirely in production. (#3850) - The deprecated HTTP MCP endpoint (
/wheels/mcp) is now read-only: only the read-onlyanalyzetool can be listed or called there. Mutating and command-executing tools (generate,migrate,test,validate,server,reload,develop) are refused with a message pointing to the canonical stdio MCP server (wheels mcp wheels). This removes an unauthenticated file-write path that was reachable over the development-only HTTP surface (including via a GET whose query promoted it to a POST). The stdio MCP server keeps all tools. (#3849) - The migrator create-migration endpoint (
/wheels/migrator/templates) now enforces the same localhost-only, no-forwarded-client, and anti-CSRF request-header checks as the migrator command endpoint. The three gate helpers are factored into a sharedvendor/wheels/public/migrator/_guard.cfminclude so both dev-tool endpoints stay in lockstep, and the create GUI now sends theX-Wheels-Csrf-Tokenheader with its request. (#3837) - The migrator template-create endpoint no longer reflects an unknown template name into its HTML response.
createMigrationnow allow-lists the requested template against the snippet files that actually exist (rejecting unknown names, embedded markup, and path-traversal attempts with a message that lists the valid templates instead of echoing the input), and the result is HTML-encoded at output. This closes a reflected-HTML/XSS path in the development-only migrator GUI. (#3845) - The Wheels CLI now validates the names and values it turns into paths and commands.
wheels deployaccepts a version (--version/--release,rollback <version>) and a destination only as 1-128 letters, digits, dots, underscores or hyphens, and writes audit log entries quoted. Deploy config load also validates the app and accessory images and every server and accessory host, and service, role and accessory names no longer accept a trailing line feed. Values resolved from.kamal/secrets, and any${VAR}interpolated intoenv.clear, are redacted from every dry-run and printed deploy command and from remote-failure messages; loading a config that interpolates intoenv.clearwarns and recommendsenv.secret, becauseenv.clearvalues travel on thedocker runcommand line of every host.wheels deploy initcreates.kamal/secretsowner-only (0600), adds it to.gitignore, never overwrites an existing one, and its template recommends$(cmd)and${VAR}lookups instead of literal values.wheels packagesaccepts only plain package names, resolves every install, removal and cache path directly undervendor/or the cache directory, and requires--yesforremove. Package tarballs are extracted into a staging directory and moved intovendor/<name>/only when they contain exactly that one directory with apackage.jsonand no links; a--forcereinstall keeps the installed package until the new one has downloaded and verified.wheels generatevalidates every name it writes into generated code (model, controller, action, view, helper and helper function, migration, route, test, policy,generate authmodel andgenerate adminmodel, plus the namesgenerate adminreads back from model introspection) and refuses to write any file outside the project, including through an existing symlinked file or directory;wheels destroylikewise refuses to delete or rewrite through a symlink that points outside the project. Model, table, property and association names (--belongsTo/--hasMany/--hasOne) and property types must be plain identifiers (letters, digits and underscores, starting with a letter, up to 128 characters), enum values may also contain hyphens, andwheels newaccepts an app name only as letters, digits, underscores and hyphens starting with a letter. Route names may also contain hyphens, and hyphens in migration and property names become underscores (create-users-table→create_users_table,display-name→display_name) with a note. Scaffolded and admin views now HTML-encode record data:wheels generate scaffoldprinted the record's display field (for example the title) unencoded in the show page's<h1>and other fields unencoded on the index page, so a record holding markup ran it in the browser of anyone viewing it.wheels generate adminprinted its index and show fields outside<cfoutput>(as literal text) and unencoded. In apps already generated withwheels generate scaffold, wrap the<h1>value inapp/views/<plural>/show.cfmand the field values inapp/views/<plural>/index.cfminencodeForHTML(), for example<h1>#encodeForHTML(post.title)#</h1>. Regenerate admin views withwheels generate admin <Model> --force.wheels generate --dry-run(includinggenerate admin) now creates no directories and writes no files, and the error for a path outside the project no longer shows absolute paths. With the RustCFML engine,wheels stop,wheels startand the server status check only act on the recorded process when it is still this project's RustCFML server: after a crash left the state file behind, a reused process ID could belong to an unrelated program, whichwheels stopkilled. A recorded process that is no longer running is forgotten; one that is running but isn't this project's RustCFML server is kept and reported by process ID onwheels stopand status, andwheels startreplaces the record.wheels destroynames are now checked likewheels generatenames (a quote or../in the name used to end up in the generated drop-table migration). (GHSA-cghp-86c6-6fjc) - Error emails no longer go to an address derived from the request. The framework used to default
errorEmailAddresstowebmaster@plus the last two labels of the Host header of whichever request started the application, and production turnssendEmailOnErroron. So a request with a crafted Host at a cold start could redirect every later error email to a domain of the requester's choosing.errorEmailAddressnow defaults to"", and with no recipient configured no error email is sent: Wheels writes a one-time warning towheels.loginstead. Apps that relied on the default must seterrorEmailAddress(orerrorEmailToAddress) explicitly. The sender falls back to the recipient whenerrorEmailFromAddressis not set. (GHSA-8gcv-3v9h-7gj2) - Values passed to dynamic finders (
findOneByEmail(),findAllByLastName(),findOrCreateBy*()), the query builder'swhere(property, value)andwhere(property, operator, value),findByKey(),updateByKey()anddeleteByKey()on string and UUID keys, andvalidatesUniquenessOf()are now bound as query parameters. These APIs used to quote the value into a WHERE string and parse that string again, so a value containing a quote could change the shape of the WHERE clause, for example to match rows other than the ones asked for, or to pass a uniqueness check it should fail. Each value is now bound as a single parameter whatever it contains, and a value with an apostrophe (O'Brien) still matches as before.whereBetween()now applies each bound as a typed comparison. Numeric columns were not affected. (GHSA-96rm-h22v-925x) - IP-based debug access (
set(allowIPBasedDebugAccess=true)withdebugAccessIPs) now grants the debug GUI and error details to the allowed client's own request only. It used to switchshowErrorInformation,showDebugInformationandenablePublicComponenton in the sharedapplication.wheelsscope for the length of that request, so a concurrent request from any other client could render its error page with full exception detail. The grant now lives in the request scope ($applyIPDebugAccess()), and every framework check reads it through$get(). Apps with the setting off (the default) are unaffected. Apps that use IP-based debug access must also updatepublic/Application.cfc: inonRequestStart(), replace the// IP-based access to public Component/debug GUIblock (thedebugIPAccessbookkeeping and the// Conditional override for allowed IPsifthat follows it; in apps generated bywheels new4.x, the body of$applyDebugIpAccessOverrides()and its call) withapplication.wo.$applyIPDebugAccess();. InonRequestEnd(), replaceapplication.wheels.showDebugInformation &&withapplication.wo.$get("showDebugInformation") &&. Until you make that edit, upgrading alone still protects you: Wheels snapshots these three flags at application start and serves the snapshot (plus the per-request grant) for them, ignoring the per-request writes the old block makes to the shared scope, and logs a one-time warning to updateApplication.cfc. The bundled debug-bar template is also gated on the per-request grant, so a concurrent request never sees another client's debug bar. (GHSA-8r22-vwcc-v55m)