Skip to content

Wheels 4.1.2

Choose a tag to compare

@github-actions github-actions released this 01 Oct 12:47
f6d6794

Added

  • Migrations can opt out of the per-step transaction with this.useTransaction = false. The migrator then runs up()/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 baseUrl setting for a canonical host. set(baseUrl="https://your-host") in config/settings.cfm makes urlFor(), 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. Explicit host/protocol/port arguments still win, and leaving it unset keeps the historical request-derived behaviour. In production Wheels 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 showErrorInformation setting once startup has got far enough to set it. When it's on (every environment except production, 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 to wheels.log and no longer prints the exception message, so a production startup failure's text no longer reaches the browser. Set showErrorInformation to true to see it on the page. The full failure is still written to wheels.log in 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 own public/Application.cfc: replace $renderMinimalError() and $logStartupFailure(), replace $startupFailureFrame() with $startupFailureFrames(), and add $startupFailureShowDetail(), $startupFailureDetailHtml() and $startupFailureRoot() from the current wheels new template (#3671)
  • set(baseUrl=...) is now parsed once when the application starts, so a malformed value stops the application from starting with Wheels.IncorrectConfiguration instead 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 from wheels new get a commented set(baseUrl=...) line in config/settings.cfm (#3842)
  • wheels generate auth: the generated Sessions.delete() (logout) now ends the session only for a POST (the logout form, which sends _method=delete) or a real DELETE request. A GET or HEAD, 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 of delete() in app/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 Host header to name the local machine, in addition to the existing loopback-socket and no-forwarded-client checks. localhost, any *.localhost name (RFC 6761), 127.0.0.0/8, and [::1] are accepted by default. If you reach the migrator under a custom local hostname (for example myapp.test via /etc/hosts, or a Valet/Herd-style *.test), it now returns 403 until you allow it with set(devToolsAllowedHosts="myapp.test") (comma-delimited, empty by default) — the same one setting the dispatch-level dev-tools gate uses. The 403 body states this fix. (#3840)
  • $prependUrl (used by urlFor(onlyPath=false) and asset URLs) still derives an absolute URL's http/https scheme from the client-controlled X-Forwarded-Proto header as before, so an app behind a TLS-terminating proxy keeps its https:// URLs. When the header is the only reason for https:// and the app has not set set(trustProxyHeaders=true), Wheels now logs a one-time warning to wheels.log. Wheels 4.2 will require trustProxyHeaders=true to honour X-Forwarded-Proto for the scheme and otherwise ignore it (matching isSecure()); apps behind a trusted reverse proxy should set trustProxyHeaders=true now. The real socket TLS state is unaffected (#3835)

Fixed

  • The apt/dnf wheels launcher no longer uses a pre-set JAVA_HOME that points at a JDK older than 21. It now checks the version, prints wheels: ignoring JAVA_HOME=<path> (Java N); Wheels needs Java 21+ to stderr, and uses the Java 21 the package installed. Previously every command except wheels --version crashed with UnsupportedClassVersionError on machines, including GitHub's Ubuntu runners, that export JAVA_HOME at Java 17. The fallback probes also skip an older default-java or alternatives java. On 4.1.1, run unset JAVA_HOME or point JAVA_HOME at 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_COLUMNS in 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 through cfdbinfo, as before. Other engines and databases are unchanged (#3732)
  • wheels test can run a single spec file. --filter / --directory accept a spec file's dotted path (tests.specs.models.UserSpec) or its bare name (UserSpec), which is looked up anywhere under tests/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 a directory= 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 with bash tools/test-local.sh <area>/<Spec>. A run that discovers no bundles no longer prints a green 0 passed line above its failure (#3759)
  • On BoxLang, a reload (?reload=true&password=… or the CLI's X-Wheels-Reload-Password header) 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's onSessionEnd handler now runs during the reload instead of being skipped with a lock-timeout error. BoxLang ends sessions on worker threads inside applicationStop(), 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 to public/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 as tests/specsOld): it used to produce a wrong dotted path like tests.specs.ld.FooSpec and 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, from tools/rustcfml/ENGINE_VERSION) instead of the stale v0.637.0 pin in RustCFMLEngine.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=rustcfml and wheels engines rustcfml install|start now 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. Its csrf option (default ignore) now applies only to the controller instance it builds, so real requests to a controller that a spec exercised with processRequest() 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 the filePath folder on disk (public/files) again when the app runs in a subdirectory whose URL rewrites fold /public/ out of the URL, or that sets set(subpath=...). The folder was built from rootPath, the app's URL path, so in those layouts it pointed one level above public/ and sendFile() threw Wheels.FileNotFound. It is now built from the web root's folder on disk. Root-anchored and mapping paths, full disk paths and an absolute directory behave 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 covers wheels generate refusals (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 bare wheels generate; wheels create, wheels destroy, wheels db and wheels upgrade with no arguments; wheels migrate pretend|forget without a version; wheels doctor with status CRITICAL; wheels analyze outside a project; wheels destroy of a name that was never generated (it also no longer writes a drop-table migration); wheels docs with an unknown action and wheels docs status outside a project; a deploy config whose servers lists no hosts; and wheels start --engine=rustcfml when 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 coverage on a warm dev server now also counts controller and model config() methods. Wheels runs config() 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 coverage no longer reports 0% when the dev server has already served the app. With Lucee's default inspectTemplate=once the server kept running the code it had compiled before coverage instrumented app/, 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 bare yyyy-mm-dd value for its <input type="date">. Bound to a date/datetime property that carries a time component (for example a datetime column), 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 fetch now 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 fetch puts in public/wheels-docs/ (about 300 MB) is no longer committed or shipped by accident. New apps list it in .gitignore, wheels deploy init lists it in .dockerignore, and the mirror now carries its own .gitignore so existing apps keep it out of git too. In an existing app's .dockerignore, add public/wheels-docs yourself. If the mirror was already committed, untrack it with git rm -r --cached public/wheels-docs. (#3855)
  • wheels docs fetch no longer deletes a public/wheels-docs folder 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's manifest.json) and only when missing or out of date. A public/wheels-docs without manifest.json is 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, and Wheels.ColumnNotFound error 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 MCP generate tool with type=app and dry-run) no longer creates the project. It printed "Dry run — nothing will be written" and then wrote the whole app, including a vendor/wheels copy and a .env with 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 called model()), but wheels.Job extends nothing, so neither was in scope and the job failed with No matching function [SENDEMAIL] found. wheels.Job now has a model() that delegates to application.wo.model(), so model("User") works inside perform(). sendEmail() stays a controller function: the example job in the app/jobs/README.md scaffold, the Job.cfc header, the consumer CLAUDE.md and 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 a create_wheels_jobs_table migration (an empty TODO migration). The wheels_jobs table is created automatically on first use (#3860)
  • The Linux .deb/.rpm wheels command no longer deletes and re-copies public/wheels-docs on 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/docs cache. It now copies the offline docs only when the mirror is missing or its manifest.json no longer matches the installed docs (so existing mirrors still refresh on upgrade), leaves a public/wheels-docs without a manifest.json alone, 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 when NO_COLOR is 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.FileNotFound from sendFile(), Wheels.ActionNotAllowed, Wheels.ActionParameterMissing) rather than mapping any Wheels.*NotFound to 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 the Table/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 (and WHEELS_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 add and update now install the stable release instead of an equal pre-release (e.g. 1.2.0 over 1.2.0-rc.1), an exact pin such as @1.2.0 no longer matches 1.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, and update no 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 holding 149.25 reloaded as 149 (and -3.75 as -3). The object then saw the column as changed, and the next save() 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 reports Wheels.RustCFML.InstallFailed with its URL instead of a "Can't cast Complex Object Type Struct to String" error. The pins live in tools/rustcfml/ENGINE_SHA256 (mirrored in RustCFMLEngine.cfc), and the daily engine auto-bump refreshes them from the release's published digests (#3824)
  • wheels generate scaffold with a boolean property, such as published:boolean, no longer generates specs that fail on SQLite with "Published is not a number". The generated test data used true; a migration's boolean column is INTEGER on SQLite, so the model's automatic numericality validation rejected it. The generated specs now use 1, which that validation accepts and which BIT/BOOLEAN columns on the other databases store as true. In specs generated with an earlier version, change "published": true to "published": 1 (#3868)
  • wheels generate scaffold: a key that can't belong to the model, such as GET /posts/abc for an integer primary key, now returns 404 instead of a 500 Wheels.InvalidValue error. The generated requireRecord() filter treats Wheels.InvalidValue from its findByKey() 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 their requireRecord() the same way; the current version is in app/snippets/CRUDContent.txt of a new wheels new app (#3868)
  • wheels generate scaffold: a request for a record that doesn't exist (GET /posts/999999) now returns 404 in production too. The generated requireRecord() filter threw Wheels.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 or app/events/onmissingtemplate.cfm in production. Controllers generated with an earlier version should replace the Throw( call in their requireRecord() with $throwErrorOrShow404Page( (same arguments), and apps that keep app/snippets/CRUDContent.txt should make the same change there so later scaffolds pick it up (#3868)
  • sendEmail(file="reports/q3.txt") attaches the file from the filePath folder on disk (public/files), as the argument docs say. Only a bare file name was resolved there; a relative path with a subfolder went to cfmailparam unchanged, 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 as sendFile()'s files; absolute paths and URLs are passed through as before (#3852) (#3857)
  • wheels new: the scaffolded session cookie is now Secure whenever the request that creates the session arrives over HTTPS, directly or through a TLS-terminating proxy that sends X-Forwarded-Proto: https, as well as for WHEELS_ENV=production. It used to depend on WHEELS_ENV=production alone. An app switched to production through config/environment.cfm (the scaffolded .env still says development) therefore issued its session cookie without Secure. Apps created with 4.0.6-4.1.1 should update this.sessionCookie.secure in public/Application.cfc to: (#3830)
    secure: (structKeyExists(variables, "currentEnv") && currentEnv == "production")
    	|| (IsBoolean(cgi.server_port_secure) && cgi.server_port_secure)
    	|| cgi.https == "on"
    	|| cgi.http_x_forwarded_proto == "https"
    (or set this.sessionCookie.secure = true; in config/app.cfm when the site is HTTPS-only). wheels generate auth also adds a spec that a GET to 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 as INTEGER and H2 as TINYINT(1), so the model treated them as integers: automatic validation rejected true and "true" with "is not a number", and a validated save() failed. The migrators now emit BOOLEAN, which both adapters read as a boolean, so true, "true" and "false" pass automatic validation and save. This applies to new columns only. Existing INTEGER / TINYINT boolean 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 pass default and allowNull again if the column had them (#3870)
  • After a failed wheels start (for example a port that is already in use), the next wheels start no 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 start also 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 start cleans 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 start refuses and points to --force instead. (#3865)
  • An invalid transaction mode (e.g. a typo such as save(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 Acceptable instead of 200 with an empty body. On a controller that only provides() html, GET /posts/2.json, /posts/2.xml or /posts/new.json used to return an empty 200. When the format param (the route's .[format] segment or ?format=) names a format outside the action's provides() / onlyProvides() list, or a provided json / xml format the action never renders (no template and no renderWith() call), and the action rendered nothing itself, the automatic render now throws Wheels.FormatNotAcceptable with status 406 in development and returns a plain-text 406 Not Acceptable response when showErrorInformation is off. Html requests, formats the action renders, renderWith() (which still falls back to the html view) and formats negotiated from the Accept header are unchanged (#3866)
  • wheels upgrade apply no longer silently downgrades the framework when the app's vendor/wheels/ is newer than the CLI's bundled copy (for example, an app created with a newer CLI or a wheels-be snapshot). It now refuses before changing anything, names both versions, and suggests installing a newer CLI; pass the new --allow-downgrade flag to install the older framework anyway. The summary now says upgraded, downgraded or reinstalled to match what actually happened (#3831)
  • wheels upgrade check without --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 --strict was bypassed. The message names the reason and suggests --to=<version>; --format=json prints a {"success": false, "error": ...} document before exiting non-zero (#3827)
  • wheels upgrade check without --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 reach https://api.github.com and every check failed with Could 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 (and WHEELS_OFFLINE=1) no longer calls GitHub to look up the latest release. upgrade check now accepts --offline, and the HTTP client that wheels packages and upgrade check use 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 --to it scans as before (#3871)
  • wheels deploy and wheels deploy setup now 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/secrets travels only on stdin), run docker buildx build --push, then pull on the hosts. Pass --skip-push when 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) and ssl (--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 rollback sends 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 when config/deploy.<name>.yml does not exist, instead of silently deploying the base configuration (#3874)
  • wheels destroy <Name> --force and wheels destroy model <Name> --force no longer generate a drop-table migration when there was no model to remove. That migration dropped a table that was never created, so the next wheels migrate latest failed.
  • Package tarballs built on macOS, which carry ._* AppleDouble metadata files, now install with wheels packages add. Those metadata files are skipped and never copied into vendor/ (#3875)
  • wheels packages update and wheels packages add --force keep the installed package when moving the verified new version into vendor/ 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 a where() 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 start gives 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, lsof and netstat can'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 /wheels development endpoints (the GUI pages, docs viewer, test runners, and the deprecated HTTP MCP) now require the request's Host header to name the local machine, and reject a non-loopback X-Forwarded-For hop, 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 with set(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-only analyze tool 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 shared vendor/wheels/public/migrator/_guard.cfm include so both dev-tool endpoints stay in lockstep, and the create GUI now sends the X-Wheels-Csrf-Token header with its request. (#3837)
  • The migrator template-create endpoint no longer reflects an unknown template name into its HTML response. createMigration now 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 deploy accepts 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 into env.clear, are redacted from every dry-run and printed deploy command and from remote-failure messages; loading a config that interpolates into env.clear warns and recommends env.secret, because env.clear values travel on the docker run command line of every host. wheels deploy init creates .kamal/secrets owner-only (0600), adds it to .gitignore, never overwrites an existing one, and its template recommends $(cmd) and ${VAR} lookups instead of literal values. wheels packages accepts only plain package names, resolves every install, removal and cache path directly under vendor/ or the cache directory, and requires --yes for remove. Package tarballs are extracted into a staging directory and moved into vendor/<name>/ only when they contain exactly that one directory with a package.json and no links; a --force reinstall keeps the installed package until the new one has downloaded and verified. wheels generate validates every name it writes into generated code (model, controller, action, view, helper and helper function, migration, route, test, policy, generate auth model and generate admin model, plus the names generate admin reads back from model introspection) and refuses to write any file outside the project, including through an existing symlinked file or directory; wheels destroy likewise 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, and wheels new accepts 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 scaffold printed 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 admin printed its index and show fields outside <cfoutput> (as literal text) and unencoded. In apps already generated with wheels generate scaffold, wrap the <h1> value in app/views/<plural>/show.cfm and the field values in app/views/<plural>/index.cfm in encodeForHTML(), for example <h1>#encodeForHTML(post.title)#</h1>. Regenerate admin views with wheels generate admin <Model> --force. wheels generate --dry-run (including generate 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 start and 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, which wheels stop killed. 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 on wheels stop and status, and wheels start replaces the record. wheels destroy names are now checked like wheels generate names (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 errorEmailAddress to webmaster@ plus the last two labels of the Host header of whichever request started the application, and production turns sendEmailOnError on. 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. errorEmailAddress now defaults to "", and with no recipient configured no error email is sent: Wheels writes a one-time warning to wheels.log instead. Apps that relied on the default must set errorEmailAddress (or errorEmailToAddress) explicitly. The sender falls back to the recipient when errorEmailFromAddress is not set. (GHSA-8gcv-3v9h-7gj2)
  • Values passed to dynamic finders (findOneByEmail(), findAllByLastName(), findOrCreateBy*()), the query builder's where(property, value) and where(property, operator, value), findByKey(), updateByKey() and deleteByKey() on string and UUID keys, and validatesUniquenessOf() 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) with debugAccessIPs) now grants the debug GUI and error details to the allowed client's own request only. It used to switch showErrorInformation, showDebugInformation and enablePublicComponent on in the shared application.wheels scope 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 update public/Application.cfc: in onRequestStart(), replace the // IP-based access to public Component/debug GUI block (the debugIPAccess bookkeeping and the // Conditional override for allowed IPs if that follows it; in apps generated by wheels new 4.x, the body of $applyDebugIpAccessOverrides() and its call) with application.wo.$applyIPDebugAccess();. In onRequestEnd(), replace application.wheels.showDebugInformation && with application.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 update Application.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)