v2.8.0
Security
- Plugin, theme, and AI mutation endpoints now enforce authorization, not just authentication — the plugin
install/activate/deactivate/update/destroyroutes andUploadThemeRequest/InstallPluginRequestgate deny-by-default onmanage-plugins/manage-themes; the themeupload/activate/updateroutes gate onmanage-themes; and both AI trigger surfaces (/api/v1/cms/ai/*and theAiToolsLivewire component) gate on a newcms.ai.useability. All three abilities are registered deny-by-default in their module service providers and seeded byPermissionsTableSeeder(granted toadmin), so a host that never seeds them is closed rather than open. Breaking for API consumers: a plausibly-privileged but ungranted authenticated user now receives403from these endpoints. Installing a plugin or activating a theme runs attacker-supplied PHP, so authentication alone was insufficient. - Plugin ZIP extraction gained the zip-slip and single-slug guards the theme extractor already had —
PluginManager::extractZip()and the plugin update/restore extraction paths now reject absolute and..entries, require every entry to live under the derived slug directory (so a sibling top-level folder can no longer overwrite a different trusted plugin), validate the derived slug, and checkextractTo()'s return value. Shared with the theme module throughCore\Updates\Support\ExtensionArchiveso the two cannot drift. - Theme and plugin archive extraction now caps the uncompressed size (
cms.themes.maxUncompressedSize/cms.plugins.maxUncompressedSize, 100MB default) before extracting, closing a zip-bomb / disk-exhaustion vector the compressed-size checks did not cover. - The legacy plugin
update_urldownload path now enforces https and the same checksum gate as the source-backed path — a plaintext feed is refused, and a feed advertising no digest is rejected unlesscms.updates.allow_unverified_updatesis set, rather than silently extracting and executing an unverified archive. - Admin menu
label/title/menuTitleare coerced to plain strings, closing theHtmlableescaping bypassNavUrlalready closed forurl; andNavUrlnow refuses protocol-relative//host(and/\host) URLs that navigate off-origin. - Re-seeding scopes the
admingrant to the framework's own permission slugs rather than every permission in the table, so a re-seed no longer sweeps in third-party/consumer permissions.
Fixed
- A caught update failure restores the pre-update snapshot before lifting maintenance mode, and honours
lift_maintenance_on_interrupt— maintenance mode is no longer lifted up front, so the site never serves public traffic from a half-applied tree while the rollback (and itscomposer install) runs; a failure on a half-applied step keeps the site down under the defaultstep_awarepolicy. - A pinned downgrade (
--target-versionwith--allow-downgrade) is reachable even when the install is already on the latest release — the latest-only "no update available" gate no longer short-circuits an explicit pinned target, andupdate:performno longer exits success while ignoring it. - A release declaring a
min_php_version/min_framework_versionthe host does not meet is refused before any file is touched, rather than installed and only failing once the new code hits an unparseable syntax. - A rollback whose extraction fails partway now throws instead of reporting a clean restore.
- The extraction-additions ledger is persisted, so a manual
update:rollbackin a fresh process removes the files an interrupted update added instead of leaving them orphaned alongside the restored snapshot. runningUpdatePid()treatsEPERMas "alive", so reconciliation no longer marks another user's healthy update run as interrupted; the no-lock fallback inacquireUpdateLock()still refuses when a live run is recorded.- Plugin update-check caching distinguishes "checked, no update" from "check failed" — a transient rate-limit or 5xx is no longer cached as "no update" for 12 hours — invalidates its cache after a successful update, and re-compares versions so a stale cache cannot trigger a redundant re-install.
- Checksum comparison is normalized through one helper (
Core\Updates\Support\ArchiveChecksum) across the application, theme, and plugin managers, so an uppercase or padded digest verifies instead of failing as a spurious mismatch. - A distinct finish-forward marker stops
update:statusfrom rendering a deliberate finish-forward as the alarming "partial update" case. ThemesController::update()returns422keyed byslugfor an unknown theme, matchingactivate()and the plugin module, and the admin layout<title>no longer double-escapes an inline@section('title', …).
Changed (this review pass)
- The theme and plugin
UpdateManagers share one update pipeline — the source-resolution, token, checksum, and version-comparison helpers now live inCore\Managers\Concerns\ManagesExtensionUpdates, and the two upload form requests shareCore\Http\Requests\ZipUploadRequest, so a fix or hardening applied once can no longer miss the near-identical copy. - The application updater's slug is configurable (
cms.updates.application_slug, defaultapplication) instead of a hard-coded product name, andcms.updates.current_version_config_keyis now honoured.
Added
PerformUpdateJobandApplicationUpdateManager::dispatchUpdate()— run the self-updater on a queue worker instead of blocking an HTTP request (#258) — 2.7.1 made an HTTP-triggeredperformUpdate()survivable (set_time_limit( 0 ), a shutdown guard that lifts maintenance mode, a persisted step marker); it did not make it appropriate. An inline update still occupies a PHP-FPM worker for several minutes and keeps occupying it after the caller disconnects, is subject to gateway timeouts (nginxproxy_read_timeout, load balancers, Cloudflare's 100s) and to FPM'srequest_terminate_timeoutthat no userland call can override, and gives the operator no feedback beyond polling the state file.dispatchUpdate()pushes the run onto a queue and returns, so an admin endpoint dispatches and pollsupdateState()rather than blocking.performUpdate()is unchanged and still supported as a direct call — the job simply calls it on a worker.php artisan update:perform --queueis the console equivalent.- The
syncqueue driver is refused rather than tolerated (#258) — dispatching tosyncexecutes the job inline in the dispatching process, which is precisely the multi-minute blocking request queueing exists to avoid, reintroduced silently while the feature looks from the outside like it works.dispatchUpdate()inspects the resolved connection's driver and throwsUpdateException::updateQueueUnusable(), naming the fix.cms.updates.queue.allow_syncopts back in for a caller that genuinely wants inline behavior. The opt-in deliberately does not rescue thenulldriver (which discards the job, so the site would never be updated) or an unconfigured connection — neither ever runs the update, so there is nothing to opt in to. The one failure the guard cannot detect from config is a well-configured connection with no worker consuming it; that surfaces as a record stuck atqueued, whichupdate:statusnames explicitly and answers with thequeue:workinvocation to run. UpdateRunStatus::Queued, andupdate:statusreporting it (#258) — "queued, not yet started" was previously indistinguishable from every other state. The record carriesqueued_at,queue_connectionandqueue_name, and keeps them for the whole run rather than only while it waits — "which worker is meant to be running this" stays the operative question until it finishes. A queued run exits zero (nothing has gone wrong, and nothing on the installation has changed) and prints the worker command that would move it along.cms.updates.queue.*(#258) —connection,queue,timeoutandallow_sync, with matchingCMS_UPDATES_QUEUE_CONNECTION/CMS_UPDATES_QUEUE/CMS_UPDATES_QUEUE_TIMEOUT/CMS_UPDATES_QUEUE_ALLOW_SYNCenv vars. The job's timeout derives from the updater's own phase budgets —download_timeout+composer_timeout+ a 900s buffer for the steps that have no timeout of their own (backup, extraction, migrations), 1,800s with the shipped values. That timeout travels with the job and takes precedence over the worker's--timeout(Worker::timeoutForJob()falls back to the worker flag only for jobs carrying none), so a short--timeoutcannot cut an update short.- A queue connection whose
retry_afteris shorter than the update is refused (#258) —retry_afteris the queue's "this reserved job must have died" timer, and Laravel ships 90 seconds fordatabase,redisandbeanstalkd, which every real update outruns. Left alone the queue hands the same update to a second worker 90 seconds in, while the first is still runningcomposer install.$tries = 1does not save that — it is what makes it land badly: the duplicate exceeds max attempts, so the worker fails it without ever callinghandle(), and it goes straight tofailed()carrying aMaxAttemptsExceededExceptionagainst a perfectly healthy run.dispatchUpdate()now throwsUpdateException::updateQueueRetryTooShort()naming both numbers and the value to raise. Only connections carrying the setting are checked; SQS expresses the same idea as a queue-side visibility timeout that is not readable from the app. - Failure reconciliation only ever touches its own run (#258) — a failing job can be handed a record it does not own.
performUpdate()takes itsflocksentinel before its own try block, so losing that race throwsupdateAlreadyRunningstraight past the bookkeeping and into the worker; and aretry_afterredelivery arrives having done no work at all. Either would have marked the winner's live recordinterruptedand runartisan upon a site that was mid-extractUpdate(), serving traffic from a half-extracted tree — the exact outcome the interruption machinery exists to prevent, caused by the machinery itself.handleFailedUpdateJob()now probes the recorded PID for liveness first and leaves other processes' runs alone. clearUpdateState()releases the dispatch lock (#258) — the record is a file understorage/and the lock is a cache entry, so they can desynchronise. Clearing a stuckqueuedrecord left dispatch refusing with "an application update is already queued" whileupdate:statusreported that none had ever been recorded — escapable only bycache:clearor by waiting out the TTL, neither discoverable, and the lock key being Laravel-internal. Discarding the record is the operator's reset button, so it now actually resets.UpdateException::updateAlreadyQueued()and::updateQueueUnusable()(#258) — a second dispatch is refused loudly rather than swallowed.dispatchUpdate()takes the job'sShouldBeUniquelock itself instead of leaving it toPerformUpdateJob::dispatch(), which takes the same lock and then silently returns when it cannot get it: a double-clicked admin button would have been told the update was queued while nothing ever ran it. Aqueuedrecord older than the job timeout stops blocking, so a host that dispatched before starting a worker is not wedged by its own first attempt.$triesis 1 and not configurable — a retry would restart the update at step 1 over a tree the previous attempt had already partly overwritten, which is the interleaving theflocksentinel exists to prevent, reintroduced by the queue rather than by a concurrent caller. Thatflockremains the real concurrency guarantee: the unique lock lives in the cache, and step 8 of the update runscache:clear.- A queued update killed mid-run no longer leaves the site serving 503s (#258) —
PerformUpdateJob::failed()reconciles the persisted record from the worker process, which matters most for a worker timeout: the run is killed mid-step, soperformUpdate()'scatchnever records the outcome and the in-process shutdown guard may never lift maintenance mode. A run killed mid-flight is stampedinterruptedand maintenance mode is lifted (honoringcms.updates.lift_maintenance_on_interrupt); a job that failed before the update started is stampedfailedrather than left claimingqueuedforever; a run that already recorded its own outcome is left alone, so the real error is never replaced by the worker's generic one. UpdateCapability— Gate abilities a host application can authorize the self-updater against (#266) —cms.updates.perform,cms.updates.rollbackandcms.updates.view, registered byCoreServiceProviderand denying by default. The framework still ships no HTTP or Livewire trigger for updates and everyupdate:*command stays console-gated, butApplicationUpdateManageris written for the HTTP case —raiseExecutionLimits()andignore_user_abort()exist to support it, andperformUpdate()'s docblock has always described the admin UI — while offering a host nothing to check.performUpdate()is by design a remote-code-execution channel: it overwrites PHP files and then runscomposer install, which executespost-install-cmdscripts from the just-overwrittencomposer.json, so a host wiring that UI now has a name to authorize against, and the shipped default for that name is deny. Grant it by seeding an RBAC permission whose slug matches (PermissionsTableSeederseeds all three, and theadminrole receives every permission), or by defining the ability in the host's ownAppServiceProvider. An ability the host has already defined is left alone.config/updates.phpanddocs/self-updater.mdnow both say plainly that the manager performs no authorization of its own and that an HTTP trigger must be gated, rate-limited and CSRF-protected. Deferred from the 2.7.1 deep review as SEC-10.- Themes have an update path (#278) — a new
Modules\Themes\Managers\UpdateManagergives themes parity with plugins: update check, in-place install over an existing theme, and rollback on failure. Themes were previously the one extension type that could only be updated by deleting the directory by hand and re-uploading — on a repo-less host, an SFTP session. The optionalupdatekey intheme.jsonis spelled identically to the plugin manifest key and resolves through the sameUpdateCheckerFactory/UpdateSourceInterface, so GitHub Releases, GitLab and custom-JSON sources all come along and archives are streamed to disk and checksum-verified exactly as they are for plugins.UpdateCheckerFactoryalready handledUpdateType::Themeand already read a theme's version out of its manifest; nothing had ever called it with that type. GET /v1/themes/updatesandPOST /v1/themes/{slug}/update— mirroringPluginsController::checkUpdates()/::update(), behind the sameauth:sanctumgroup, returning updates in the same payload shape so one admin component can render both extension types. Update failures surface as aValidationExceptionerror bag rather than a bare JSON body, so host apps using Inertia get a working error bag (#124).ap.cmsFramework.theme.updating/ap.cmsFramework.theme.updated— theme update lifecycle hooks, joining the install and activation hooks from #123.updatingreceives( $slug, $oldVersion, $newVersion )and listeners may veto by throwing;updatedreceives( $slug, $newVersion, $manifest ).cms.themes.updateCacheTtl,cms.themes.backupPath,cms.themes.updateTokens,cms.themes.maxUpdateSize— update-check TTL, the directory an installed theme is archived to before it is replaced, per-slug access tokens for themes whoseupdatesource is a private repository, and a size ceiling for downloaded update archives. The last is deliberately separate frommaxUploadSize: that one is an abuse control on the upload endpoint, and a theme shipping images and fonts clears its 10MB default easily — gating updates on it would leave such a theme permanently un-updatable, which is the exact situation this feature exists to fix.ThemeUpdateException— mirrorsPluginUpdateException, with factories for a failed backup, a failed swap, a theme declaring no update source, and a verbatim failure reason so an integrity failure surfaces as what it is.ThemeManager::stageThemeFromZip()/::swapStagedTheme()— extract and fully validate an update archive inthemes/.updates/before anything replaces the installed theme, then swap it into place with tworename()calls. This is what lets the active theme be updated without maintenance mode: the window during which its directory does not exist is a single syscall wide, and a bad archive is rejected before the swap is reached rather than after it has deleted a working theme. Rollback deletes the live directory outright before re-extracting the backup, so files a failed update added are removed rather than left orphaned alongside the restored ones (#272).- Plugins can update from GitHub Releases (#277) — a new optional
updatekey inplugin.jsondeclares where self-updates come from, andModules\Plugins\Managers\UpdateManagerresolves it through the sameUpdateCheckerFactory/UpdateSourceInterfacethe application updater uses.{"update": {"github": "owner/repo"}}is shorthand for the GitHub Releases source;{"update": {"url": "https://..."}}is handed to the source detector as-is, so GitLab repositories and custom JSON endpoints fall out of the same key. Both forms are https-only. Publishing a plugin update is nowgit tagplus a GitHub Release, with no separately-hosted JSON feed.UpdateCheckerFactoryalready acceptedUpdateType::Pluginand already resolved a plugin's installed version out of thepluginstable — nothing had ever called it with that type. cms.plugins.updateTokens— access tokens for plugins whoseupdatesource is a private repository, keyed by plugin slug. Deliberately per-slug rather than one global token: a plugin names its own update host in its own manifest, so a shared token would be handed to whatever host any installed plugin asks for. Public repositories need no entry.PluginUpdateException::updateFailed()— carries a reason string, so an integrity failure surfaces as what it is instead of being collapsed intodownloadFailed()'s "Failed to download update for plugin".- A
cmsview namespace, andcms::admin.layouts.appinside it (#246) — the layout Blade admin pages extend.examples/hello-world-plugin/has extended it since it was written andgrep -rn "loadViewsFrom.*'cms'"returned nothing, so the reference plugin died onNo hint path defined for [cms]. The layout is deliberately plain — the framework is front-end agnostic and ships no CSS build — and does three things: renders the menu fromapGetAdminMenu(), yields acontentsection under atitle, and exposesstyles/scriptsstacks. Hosts replace it with their own chrome viaphp artisan vendor:publish --tag=cms-views, which lands inresources/views/vendor/cms/— the path Laravel resolves ahead of the package's own, so a host swapping in real chrome requires no plugin to change the view it extends. Registered fromCMSFrameworkServiceProviderrather than a module provider so the namespace exists for every consumer regardless of which modules are enabled.
Changed
docs/self-updater.mdno longer claims that nothing serializes concurrent updates (#258) — the paragraph predated the 2.7.1flocksentinel and contradicted the "Concurrent updates (2.7.1)" section two screens above it, telling operators to build a lock the framework already takes for them.- AI feature keys are read from the agent that owns them, not respelled per endpoint (#176) — the five
cms.*keys were hardcoded in ten places (AiControllerandAiTools, five methods each) and a further five times inCMSFrameworkServiceProvider::aiFeatures(), even though every agent already declarespublic string $featureKey. Renaming one meant editing six-plus files with nothing to catch a miss.runAgent()/run()now take the agent class plus its input and resolve the key themselves, andaiFeatures()is derived from a newpublic const AI_AGENTSrather than respelling the map — so a key is spelled in exactly two places, the agent that owns it andAI_FEATURE_KEYS, with a test asserting the two agree. Purely internal: the JSON envelopes, theap-cms-ai:{key}:{status}event names, and theaiFeatures()return shape and key ordering are all unchanged. - Agent metadata is read off the declared class rather than a container-resolved instance (#176) —
docs/AI-Features.mdinvites hosts to bind a subclass over an agent, which makes construction a genuine throw site and a subclass's$featureKeya genuine source of divergence. Resolving metadata through the container would have meant two distinct faults: a failed binding escaping the very handler that builds the error envelope, so an HTTP caller loses its JSON body and a Livewire caller never receives a status event at all; andaiFeatures()keyed by the override while'agent'still named the original class andAI_FEATURE_KEYSstill listed the original key, leaving three sources disagreeing. The newAi\Support\AgentMetareflects declared property defaults instead — it cannot throw, and it cannot disagree with the constant. Agent construction stays inside the wrappers'tryblock, and new tests cover all four exception categories on both trigger surfaces plus the construction-failure path. cms.themes.defaultno longer names a specific consumer's theme (#125) — breaking for anyone relying on the implicit fallback. The shipped config defaulted to'digital-shopfront', andThemeManager::getActiveTheme(),ThemeManager::markActiveTheme()andThemesServiceProvider::boot()each repeated that literal as the third argument to theirconfig()call, so removing or nulling the config key changed nothing — the slug was hard-coded in three more places than the one you could edit. The framework bundles no themes, so a consumer shipping a differently-named default got a silent lookup for a theme that does not exist:getActiveTheme()returningnullfor a reason that had nothing to do with their install. The config key is nowenv( 'CMS_DEFAULT_THEME' )and all three literals are gone. Unconfigured, "no theme is active yet" is an explicit state rather than a missed lookup —getActiveTheme()returnsnull,registerThemeViewPath()early-returns and leaves the host's view paths alone,markActiveTheme()flags every discovered theme inactive, and the site-editor resolvers take their existing theme-less paths. The registeredthemes.activeThemesetting default tracks the same config value, becauseSettingsManager::getSetting()falls through to it once the caller's own default is null — leaving a literal there would have reinstated the fallback the config removed. To keep the old behavior, setCMS_DEFAULT_THEME=digital-shopfront(or activate a theme explicitly, which consumers that callactivateTheme()already do — for them nothing changes, since a storedthemes.activeThemehas always won over the default). Same family of fix as #120.- Themes and Plugins action endpoints fail as a
ValidationException, so Inertia's error bag works (#124) — every failing action onPOST /v1/themes,POST /v1/themes/{slug}/activate,POST /api/v1/plugins/install, and the pluginactivate/deactivate/update/destroyendpoints now returns422carrying Laravel's{"message": "...", "errors": {"field": ["..."]}}shape, keyed by the field the failure belongs to —theme_zip/plugin_zipfor the uploads,slugfor the actions taken against an installed extension. The previous bodies weremessage-only, which Inertia lands as a generic exception rather than populatingusePage().props.errorsanduseForm().errors, so an admin UI could not render field-level messages without forking the controllers. Pure-API consumers gain a parseableerrorsobject and keep themessagethey already read. NewUploadThemeRequestandInstallPluginRequestform requests front-load the upload rules — presence, ZIP MIME type, and the configured size ceiling — so those failures arrive in the same shape as the manager-level rejections they sit in front of.IncompatiblePluginExceptiondeliberately keeps its409and its structuredcode/required_version/host_versionpayload, which a flat error bag cannot express. The422covers request validation and the managers' own named rejections; an unexpected fault is reported and returns500on every Themes endpoint rather than being dressed up as a field error —ThemesController::update()was converting those into a422since 2.8.0 and now matchesupload()andactivate(). The Plugins endpoints raise the same generic422they always have on an unexpected fault, but no longer echo the raw exception message into it: the detail goes toreport()instead of to the client, so a database, filesystem, or host-hook failure can no longer disclose internal paths through an endpoint reachable by any authenticated user. POST /api/v1/plugins/{slug}/updatedistinguishes "already up to date" from "updated" (#124) —UpdateManager::updatePlugin()returnsfalsewhen no update is available, which the controller discarded, reporting"Plugin updated successfully"for a no-op. The response now carries anupdatedboolean alongside the matching message, the same shapePOST /v1/themes/{slug}/updatealready returned.POST /v1/themes/{slug}/activateanswers an unknown slug with422instead of404(#124) — breaking for consumers branching on the status. The slug is form input on this endpoint, not a resource path: an admin activating a theme from a list that has drifted out of sync with the themes directory should see a field error on the control they used, not a hard error page.GET /v1/themes/{slug}is unaffected and still answers404, because there the slug is the resource path.- The
updatemanifest key is validated by one shared implementation (#278) — the rules moved fromPluginManagerintoHasManifestParsing, which both managers already use, so plugins and themes cannot drift on a key they deliberately spell identically. Each manager still raises its own exception type. No behavior change for plugins; the messages are byte-identical. - Source-backed plugin updates stream to disk and are checksum-verified (#277) — a plugin using the new
updatekey downloads through its update source, which streams the archive viaStreamsDownloadsToDiskrather than buffering the whole ZIP in memory (the OOM shape fixed for the core updater in #214 / #216 / #219) and enforces https across the initial request and every redirect. The archive is then verified against the SHA-256 the release advertises — a{asset}.zip.sha256sidecar, or aSHA-256:line in the release body — honoringcms.updates.verify_checksumandcms.updates.allow_unverified_updatesexactly asApplicationUpdateManagerdoes. With the shipped defaults a release publishing neither is refused; see #271 for the publishing-side workflow change that attaches the sidecar. - Plugin update checks normalize on
UpdateInfointernally, which is what makessha256reachable at all. The payload atGET /api/v1/plugins/updatesis unchanged: source-backed results are flattened onto the sameversion/download_urlkeys the endpoint has always returned, withsha256,changelog,release_date,file_sizeandmetadataadded alongside. Plugins declaring only the legacyupdate_urlkeep the existing custom-JSON behavior verbatim — raw feed payload as the response body, no checksum enforcement. UpdateCheckerno longer evicts plugin and theme cache entries usingapp.version— the staleness heuristic added in 2.5.3 compares a cachedcurrentVersionagainst the host application's version, which for a plugin is a different number by construction. Every plugin cache entry was therefore stale on its first read, so the cache was written and never served. The heuristic now applies only toUpdateType::Application.
Deprecated
Removed
Security
-
The admin menu's URL allow-list is enforced where it can no longer be walked around (#246) — three gaps in
sanitizeExternalUrl()'s coverage, all latent while every consumer rendered the menu itself and all reachable the moment this release shipscms::admin.partials.menu, which putsurlinto an<a href>inside the package. Blade's escaping does not stop ajavascript:scheme, so each one executed attacker JS in an authenticated admin session on click.- The filter ran after sanitization.
decorateItem()checks a pre-seturl, but decoration happens beforeap.cmsFramework.admin.menu, so a row a subscriber injects had never been checked — and the documented injection shape, a bare array with aurland noroute, also takesdecorateItem()'s early return.getAdminMenu()now runs the post-filter tree through a recursivesanitizeMenuUrls()pass coveringitemsandsubItemsas well as top-level rows. The pass is idempotent, so URLs already sanitized during decoration are unaffected, andregisterNavEntry()'s ingress check is unchanged. - The scheme regex could be stepped over.
trim()only touches the ends, sojava	script:alert(1)failed^[a-zA-Z][a-zA-Z0-9+.\-]*:, fell through the allow-list as a "relative reference", and survivedhtmlspecialchars()— which leaves tabs, newlines and C0 controls alone — to reach the browser, whose URL parser strips exactly those characters before resolving the scheme. Same for a leading\x01. The value is now normalized the way the URL parser normalizes it (tab/CR/LF removed anywhere, leading and trailing C0 and spaces stripped) before the scheme check, and the normalized form is what is returned, so the checked value and the rendered value cannot diverge. - A non-string
urlskipped both layers. The guard wasis_string(), and Laravel'se()returns anHtmlable—HtmlStringamong them — unescaped, so'url' => new HtmlString( 'javascript:alert(1)' )missed the allow-list and the escaping. Values now go throughNavUrl::sanitizeValue(), which resolves aStringablebefore checking it; anything not stringable becomes'#'.
Relatedly,
PluginServiceProvider::normalizeNavUrl()now takesmixedrather thanstring.registerNavEntry()receives a plugin-supplied array and the file declaresstrict_types=1, so aurlthat was anything but a string — anHtmlString, an int, an accidental array — raised a TypeError at the parameter boundary inside the plugin'sboot(): the same whole-application failure mode as the route-action bug above, from a plugin typo. Those values now take the documented'#'fallback. Widening the parameter is a signature change on aprotectedmethod of an abstract base class, so a plugin that overridesnormalizeNavUrl( string $url )— no reason to, it is an internal sanitizer, but the method is part of the extension surface — must widen its own parameter to match.The rules themselves moved into a new
Modules\Admin\Support\NavUrl, because they existed twice — once on the render path inAdminMenuManager::sanitizeExternalUrl(), once on the registry ingress path inPluginServiceProvider::normalizeNavUrl()— with the same regex and the same scheme list. That duplication is precisely how one copy came to be hardened while the other kept writing the un-normalized value intoPluginRegistry, which is a public surface in its own right:PluginRegistry::navEntries()andPlugin::getNavEntriesAttribute()are read directly, not only throughgetAdminMenu(). Both now delegate, so the next change to the allow-list is one edit. Behavior for every URL form the allow-list already accepted —http(s),mailto:,tel:,/-relative,#-fragment, and scheme-less relative paths — is unchanged, and pinned by a dataset. - The filter ran after sanitization.
-
Plugin update sources are re-checked for https at the point of use, not only during install-time manifest validation.
UpdateManager::updatePlugin()refreshes a plugin'smetastraight from the manifest inside the downloaded ZIP and never re-runsPluginManager::validateManifest(), so an update can seat anupdatevalue that never passed validation. A plaintext source is not cosmetic there:CustomJsonUpdateSourcewould fetch the update metadata over http, and a network attacker rewriting that response chooses both the download URL and thesha256it is checked against — digest and archive come from the same document, so verification would confirm the attacker's own archive, which is then extracted intoplugins/and executed as PHP. A non-https source is now ignored with a warning instead of being fetched.
Fixed
-
The lock-sync check no longer aborts updates composer would have installed (#264) — 2.7.1's
verifyComposerFilesInSync()(#255) treated anycontent-hashdivergence betweencomposer.jsonandcomposer.lockas fatal, aborting at step 6 and triggering a full backup rollback. Butcomposer installinstalls from the lock despite a stale hash — it only warns — and hard-fails solely when the lock cannot satisfycomposer.json(a required package missing from the lock, or a constraint it violates), confirmed against composer 2.10.1 (Installer.php/Locker.php). So the check converted a composer warning into a full-rollback abort for updates composer would have completed: a release that changed onlyextra,version, orrepositorieswhile shipping an old-but-satisfying lock hit it. The pre-emptive abort is removed. The check is now a diagnostic: on a detected divergence it logs a warning and lets composer adjudicate, and only when composer itself fails doesUpdateException::composerInstallFailedwrap composer's output with the framework's accurate "this is not a merge conflict — the release likely shipped no matchingcomposer.lock" explanation, replacing composer's misleading "incorrectly merged or manually edited" guess. The valuable half of #255 is kept; the overreach that could abort a real host's update is dropped.UpdateException::composerFilesOutOfSync(unused after this change) is removed;verify_composer_lock_sync = falsestill switches the diagnostic off entirely. -
A plugin admin page registered with a Blade
viewrenders instead of throwingInvalid route action(#246) —PluginServiceProvider::registerAdminPage()passed$config['view']straight through as the page'saction, andAdminPageManager::registerRoutes()hands that toRoute::get( $slug, $action ). Laravel accepts a closure, a controller class, aClass@methodstring or an array there — a Blade view name is none of them, so it was read as an invokable controller class and rejected. The flavor had never worked, and the blast radius was the whole application rather than the plugin's own page:registerRoutes()runs insideAdminServiceProvider's$this->app->booted()callback, so the exception fired before routing and 500'd every URL, admin or not, for any host with such a plugin active — the bundledhello-worldexample among them. The view name is now wrapped in a closure that renders it, with the route's own parameters passed through as view data so a page registered atreports/{id}can read$id. Closure actions stayroute:cache-safe — Laravel serializes them viaSerializableClosure.docs/admin/Menu-and-Pages.mddescribedactionas accepting a "view response", which is the same misreading one layer down: the rawapAddAdminPage()helper does passactiontoRoute::get()unchanged, so a caller using it directly must wrap a view themselves, and the doc now says so and shows the wrap. Acomponentis still passed through verbatim and therefore still throws — how a host mounts a federated page is a design question this fix deliberately does not answer, sodocs/plugin-authoring.mdand the example plugin's README now carry an explicit warning not to use that flavor yet, where they previously advertised it as working. Tracked as #296, along with the neither-view-nor-componentcase, which throws the same way. -
The bundled admin layout honors
showInMenu => false(#246) —addSubPage()has always stored the flag anddocs/admin/Menu-and-Pages.mdhas always documented it as "routed but hidden from menu", but nothing ingetAdminMenu()ever read it back; it was live only to the extent that each consumer's own renderer checked it. Shippingcms::admin.partials.menuin this release would have made the framework the thing that ignores it, putting a link toposts/editin the sidebar. The renderer filters on it.getAdminMenu()'s payload is deliberately unchanged — hosts reading the flag themselves keep receiving those rows. -
cms-framework-configis a real publish tag, so the documented install step publishes something (#290) —README.mdhas always told consumers to runphp artisan vendor:publish --tag=cms-framework-config, and that tag was registered nowhere insrc/.vendor:publishexits0on a tag matching nothing, so following the README produced an emptyconfig/, no framework config files, and no indication anything had gone wrong — the failure was indistinguishable from success. Each of the four config files is now tagged twice: under its existing module-only tag (artisanpack-package-config,cms-themes-config,cms-plugins-config,cms-updates-config), and under the umbrellacms-framework-configthe README documents, so the one-liner publishes all four and a consumer who wants one module's config in isolation keeps that option. The umbrella tag is also the narrowest way to ask for this package's config specifically:artisanpack-package-configis an ecosystem-wide convention shared by every ArtisanPack UI package, so in a host with several of them installed it publishes far more than the CMS framework's file. No existing tag changed, so nothing that already worked breaks.docs/Installation-Guide.mdanddocs/Configuration.mdwere separately instructing--tag="config", which was also never registered, and both named the main config fileconfig/cms-framework.phpwhen it publishes toconfig/artisanpack/cms-framework.php; both are corrected.docs/themes.mdlocated the theme settings atconfig/cms.phpunder athemeskey and showed the file wrapped in that key — the published path isconfig/cms/themes.phpand it returns the settings array directly, since the module merges it undercms.themes— which is what made #125's new "edit the published config directly" guidance unreachable in two different ways at once. A newConfigPublishTagsTestasserts every source file is reachable through both its tags and lands at the documented destination, and that every--tag=the README names is actually registered, so this particular drift fails at test time rather than silently at install time. -
comments.form.actionis aliased toap.cmsFramework.comments.form.action, completing a wave 4b rename that never landed (#245) — the 2.5.0 wave 4b table (#194) listed this filter as renamed, and the CHANGELOG entry below says so, but no entry was ever added toHookAliases::wave4bLifecycle(). The rename was therefore documentation-only: the new name resolved to nothing, and a host app that renamed its subscriber on the strength of the CHANGELOG would have silently stopped receiving the filter. Downstream apps had to stay on the un-prefixed name. The alias now exists and resolves in both directions, so a host may subscribe under either name. This filter's only fire site is inartisanpack-ui/visual-editor(thepost-comments-formblock), not in this package — it is namespaced here because comments are this package's domain and the filter's default value is this package'sPOST /api/v1/commentsendpoint, the same emitter-is-not-owner split asap.rbac.roleRegistered. visual-editor declares the identical alias on its own side, so the old name keeps resolving in installs that do not have the CMS Framework; because the alias is registered on both sides, a host may rename its subscriber toap.cmsFramework.comments.form.actionimmediately, without waiting for the visual-editor release that switches the fire site over. -
The release workflow publishes a release archive and a SHA-256 sidecar, so a GitHub-sourced host can self-update with the shipped defaults (#271) — 2.7.1 taught
GitHubUpdateSourceto discover a checksum, butrelease.ymlattached no assets for it to discover, so releases carried only GitHub's auto-generated zipball. The zipball has no asset name,extractChecksumFromSidecar()refuses to correlate a digest against an unnamed target, and the CHANGELOG-derived notes carry noSHA-256:marker — sosha256stayed null and, withverify_checksum = trueandallow_unverified_updates = false, every GitHub-sourced update was refused. Thereleasejob now buildscms-framework-{version}.zipwithgit archive— whose bytes are stable for a given tree, unlike the zipball's — writescms-framework-{version}.zip.sha256beside it, and attaches both. The sidecar name is the exact string the source correlates on, so a newReleaseWorkflowTestasserts the two sides still agree rather than leaving the next rename to fail closed at tag time. The job also refuses to publish an archive missingcomposer.lock, which the updater installs from rather than re-resolving (#255). -
A plugin update that fails while taking its backup no longer trips an undefined-variable error —
updatePlugin()referenced$backupPathfrom itscatchblocks, but the variable is assigned by the first statement of thetry. WhenbackupPlugin()itself threw, the recovery path died on the undefined variable instead of reporting the backup failure.restoreFromBackup()now takes a nullable path and returns early when there is no backup to restore from, rather than deleting a working install it has nothing to replace.