Releases: ci4-cms-erp/ci4ms
Release list
0.35.0.0 — Signed Updates (Ed25519), In-App Notifications with Realtime SSE, and a Broad Security Hardening Pass
Highlights
This release introduces cryptographically signed auto-updates, a full in-app notification system with realtime SSE delivery, and closes 6 security vulnerabilities (2 Critical, 4 High) found during an internal audit. It also extends declare(strict_types=1) to 76 files, adds regression tests for the crash paths that surfaced, and fixes the CI pipeline so test failures are no longer silently swallowed.
✨ Added
- Signed Update Manifests (Ed25519, fail-closed) — The one-click updater now requires a detached Ed25519 signature on every release. Without the publisher's offline private key, zero bytes of code are written — even if GitHub, the release, and the CDN are all compromised. Gates: signature verification → version binding → per-file SHA-256 → atomic apply. No "continue anyway" escape hatch exists.
- Release Signing Keyring (
UpdateKeys) — Ships empty by default (auto-update disabled until the operator adds a trusted public key out of band). Supports key rotation via dual-signing and hard revocation. - Offline Signing CLI —
ci4ms:release:keygen,ci4ms:release:manifest,ci4ms:release:verify. Private key never enters GitHub or CI; sealed with argon2id, read via hidden terminal prompt, wiped withsodium_memzero(). - In-App Notifications Module (Model B) — Server-side notifications with bell dropdown, single-global-row design (no fan-out), per-user read state, severity levels, IDOR-safe relevance filtering, and
NotificationMessageDTO with URL sanitisation (javascript:/data:blocked). - Realtime SSE (Redis-backed, optional) — Instant badge updates via Server-Sent Events. Self-contained (no external hub or JWT), disabled by default, graceful degradation to 60s polling. Role-aware per-user connection cap via atomic Redis Lua to prevent FPM worker exhaustion.
- Rich Targeting & Per-User Preferences (Phase 2) — Multi-user + multi-group dispatch with automatic overlap deduplication,
exceptUser()explicit exclusion (fail-closed), and a per-user opt-out preferences screen. - Backend Cache Management Panel — Selective or bulk cache purge from Settings, with server-side allowlist (
CacheRegistry) preventing glob injection. Shield dynamic RBAC config key is a protected key that can never be cleared.
🐛 Fixed
json_decode()flag in wrong argument slot — 2 sites passedJSON_UNESCAPED_UNICODEas$associative(position 2); worked by accident. Now passestrueexplicitly. (Reported by @0xAlchemist)- Sitemap joined on wrong column —
pages_langs.idinstead ofpages_langs.pages_id, dropping and mis-attributing localized URLs. - CI PHPUnit step silently swallowed failures —
2>/dev/null || echo …masked non-zero exit codes; removed so test failures now fail the workflow. - Regression tests for crash paths — 13 tests for
buildSeoData()(invalid JSON, invalid UTF-8, object keywords) and 10 tests forcheckTags()(data-loss scenario: pivot deletion beforeforeach(null)crash). CommonModel mocked via reflection.
♻️ Changed
declare(strict_types=1)extended to 76 files (was 28). Libraries, Models, Commands, Filters only — not Views or Language files. Follow-up review caught 8 silent coercions turned fatal by strict mode and guarded each one.
🔒 Security
- Critical — Stored XSS via GitHub release metadata in Update Panel —
tag_nameand filenames rendered unescaped into SweetAlert2'shtml:sink, bypassing the entire Ed25519 gate at a different layer. Now allowlist-validated at source and escaped at view. - Critical — Test suite wrote to the live database —
CommonModelbypassed CI4's$defaultGroup = 'tests'override by requesting group'default'by name. Observed: 8 duplicateauth_groupsrows inserted into production during test runs. Fixed by aliasing group data under testing environment. - High — SSRF + GitHub PAT leak via unvalidated
commits[].url— attacker-controlled URL received the configured token. Now allowlisted toapi.github.com. - High — Signed-but-hollow update — Crafted compare response could mark all files as removed, apply zero changes, bump
.envversion, and permanently pin the installation. Closed withremoved_but_signed/empty_apply_set/ conditional version write. - High — Path traversal in
rollbackUpdate()—backup_namePOST concatenated into filesystem path.../../publicand../tmpboth resolved. Now usesbasename()+hash_equals()match against real listing. - High — Stored XSS via theme
info.xmland backup directory names — 3 more instances of the same unescaped-into-markup pattern. All closed withesc()+escHtml()+addEventListenerreplacing inlineonclick. - High — Media upload MIME allowlist only stopped
.php—.phtml,.php5,.pht,.phar,.php.jpgall accepted. Closed withDENIED_EXTENSIONSchecking every dot-separated segment. - High —
fieldExists()migration guards silently stale — CI4's column cache not invalidated after ALTER TABLE.resetDataCache()added to all affected migrations. - High — Fresh install failed on Settings seed — Missing
created_at/updated_atwith MySQL strict mode. Timestamps now set explicitly.
📊 Test Coverage
| Suite | Tests | Assertions |
|---|---|---|
| Settings (ManifestVerifier, UpdateGate, Release, Rollback) | 105 | 484 |
| Media Upload Gate | 66 | — |
| Notifications (Notifier, Preferences, SSE, ConnectionRegistry) | 80+ | — |
| Backend Libraries (buildSeoData, checkTags, Maintenance) | 23 | — |
| Health + Feature | 5 | — |
⚠️ Breaking / Operator Action Required
- A real
testsdatabase group is now mandatory — configuredatabase.tests.*in.env(schema name ending in_test, never the live DB). - Auto-update is disabled by default — add your Ed25519 public key to
UpdateKeys.phpto enable signed updates. - SSE realtime is off by default — set
notificationsconfig.realtimeEnabled = trueand register theRealtimeControllerroute permission via Methods module scan before enabling.
📦 Upgrade
# Standard upgrade path
composer update
php spark migrate --all
php spark cache:clearor you can
php spark ci4ms:migrateFull Changelog: 0.34.1.0...0.35.0.0
0.34.1.0
This release adds a full Notification Center to the backend — in-app notifications with optional realtime delivery, per-user opt-out preferences, and an admin composer — plus a Cache Management panel in Settings, and closes a family of settings-cache bugs that could silently disable, or falsely trigger, maintenance mode.
⚠️ Upgrade steps (required). After pulling this release:
php spark migrate --all(orphp spark migrate -n "Modules\Notifications")- Backend → Methods / Modules → Module Scan, then
php spark cache:clear- In the group matrix, grant the notification send permissions (
notifications.notifcomposesend.create,notifications.notifcomposepreview.create) deliberately — do not tick every box in the module. The bell permissions belong to every backend user; the right to broadcast to the whole installation does not.Until the scan runs, the new endpoints are fail-closed
403for everyone, superadmin included.
💡 Realtime is opt-in. Realtime delivery is disabled by default (
notificationsconfig.realtimeEnabled). With it off, the bell behaves exactly as the 60 s polling model. Enabling it requiresext-redisand apm.max_childrensized for your concurrent admin count — each open stream holds a PHP-FPM worker for up torealtimeStreamTtlseconds. No external hub, broker, or nginx config change is needed.
✨ Added
-
Notification Center (
Modules\Notifications). In-app notifications for administrators, surfaced as a bell dropdown in the backend header and a full list page.- Model B storage. Each notification is stored once as a global row targeted with
target_type(broadcast|user|group); per-user read state lives in a separatenotification_readstable. Sending to a 50-member group writes one row, not fifty. - One access-control chokepoint.
Notifier::applyRelevance()is the single place that decides what a user may see, shared by every read path — a user only ever sees broadcasts, their ownuserrows, and rows for groups they belong to. - Realtime delivery over Redis-backed SSE (opt-in). The badge updates the moment a notification is produced instead of waiting for the poll. Self-contained — no external hub and no JWT — and the client never trusts the pushed payload: a message only triggers a reconcile against the database, so the DB stays the source of truth. Falls back to polling if the stream drops.
- Role-aware connection cap. Concurrent SSE streams per identity are capped (
realtimeConnCapDefault, with per-group overrides), enforced by a single atomic LuaEVALover a Redis sorted set. Fail-closed if Redis is unreachable. - Rich targeting. One dispatch can address several users and several groups, with
exceptUser()exclusions. Overlapping user/group targets are collapsed so a recipient sees the notification once. - Per-user preferences. Administrators can mute notification types from a preferences screen; the filter is applied at read time.
criticalnotifications cannot be muted. - Admin composer. A "Send notification" screen with server-validated audience selection, a recipient-count preview, and a
created_byaccountability column recording who published each notification. - Ships a
php spark notifications:purgecommand for retention (no cron is installed — schedule it yourself) andphp spark notifications:testfor verifying an installation.
- Model B storage. Each notification is stored once as a global row targeted with
-
Settings → Cache Management panel. Administrators can selectively purge cacheable keys through a select2 multi-select, or clear all clearable keys at once. A server-side allowlist (
Modules\Settings\Libraries\CacheRegistry) is the single source of truth: the client sends only logical ids and glob patterns are resolved on the server, so glob injection is not possible. Purges are targeted — the framework-widecache:clear/clean()is never invoked — and the Shield dynamic RBAC config key is protected from clearing entirely.
🔐 Security
- Bell identity hardening. The bell view component now derives the current user strictly from
auth()->id()rather than any caller-supplied identifier, closing a footgun where a mismatched id could have surfaced another user's unread feed. - Bound
user_idin relevance joins (defense in depth).Notifierbinds$userIdthroughdb->escape()in the read-side joins and predicates, hardening the path even though the value already originates from the authenticated session.
🔁 Changed
- CodeIgniter upgraded 4.7.2 → 4.7.4.
cache('settings')decode canonicalized across every warm-up path. Thesettingscache key can be primed by whichever entry point touches it first — the global filter, the generated routes file, or the Auth base controller. These had drifted apart and produced structurally different payloads for the same key, so behaviour depended on which request happened to warm the cache. All of them now use byte-identical decode logic.Config\Format::$jsonEncodeDepthadded. CI 4.7.4'sJSONFormatterreads this property without a fallback, so it has to exist or every JSON response throws.
🐞 Fixed
- Maintenance mode could be silently disabled — or falsely triggered. Two independent defects in the settings-cache fillers took maintenance mode out of the operator's control: one filler passed
JSON_UNESCAPED_UNICODEintojson_decode()'s$associativeparameter slot (producing a half-array/half-object payload), and another warmed the same cache with the raw, undecoded database rows, losing the entire settings map whenever the login flow primed a cold cache. A storedmaintenanceMode = "0"could also evaluate astrue, forcing the site into maintenance while it was actually off. - PHP 8
Cannot use object of type stdClass as arrayfatals in settings-driven frontend views (fonts, theme assets, widgets, footer links), plussocialNetworkrendering on both the frontend and the backend settings form. - WebP conversion silently disabled while its setting was enabled, and the maintenance page failing to render — two scalar settings reads left on a removed object-cast artifact.
php spark migrateaborting withDuplicate column name 'allowed_groups'. A migration added a column with no idempotency guard; on an installation where the column existed but the ledger row did not, every run failed at the same point and blocked all later migrations. That migration and four others carrying the same latent defect are now guarded — behaviour-neutral on a consistent database.- Dashboard "unread notifications" widget queried an
is_readcolumn that does not exist under the new schema, so its counter could never be correct. It now delegates to the notification module's own cached counter and cannot drift from the bell badge.
Full detail: see CHANGELOG.md — the 0.34.1.0 block documents the design decisions, trade-offs and known limits behind each item.
0.34.0.0
This release introduces an opt-in, privacy-first Session Geo Lookup subsystem and replaces the old inline ip-api.com call that could crash every login, alongside a batch of auto-updater resilience fixes.
⚠️ Upgrade note (behavior change): Session geo enrichment is now disabled by default. After upgrading, existing installations stop collecting geo data until an administrator enables Settings → Session Location Tracking and runsphp spark ci4ms:geoip-updateto download the local DB-IP City Lite database. The visitor's IP address is no longer sent to any third party.
✨ Added
- Local Session Geo Lookup subsystem (opt-in, privacy-first). Login sessions can be enriched with an approximate city / region / country, derived entirely from a local DB-IP City Lite database — the IP never leaves your server, and no third-party request is made.
- New
Modules\Auth\Libraries\GeoLocatorreads the MMDB file viamaxmind-db/reader. - New
php spark ci4ms:geoip-updatecommand downloads the database, gunzips it, verifies it with a test lookup, and atomically swaps it into place (flock-guarded, cron-friendly). - Gated by the
Auth.geoLookupEnabledsetting (defaultfalse), exposed as a backend Settings → Session Location Tracking toggle (AJAX +role=update+ CSRF + validation, with a warning when enabled while the database is still missing) and as an opt-in checkbox in the web installer. - Adds the
maxmind-db/readerdependency and new English/Turkish language keys. - DB-IP attribution (CC BY 4.0) is surfaced in the README, the installer, and the command output.
- New
🔁 Changed
- Session geo lookup replaces the previous inline
ip-api.comHTTP call. Privacy-by-default: geo collection is off until enabled, and IPs are no longer transmitted to third parties over plain HTTP.
🐞 Fixed
- Login crash when a DNS-level blocker (e.g. Pi-hole) or an outage made
ip-api.comunreachable —json_decode(file_get_contents(...))returningfalseraised an uncaughtTypeErrorunderstrict_types, turning every login into a 500 (the@operator does not suppress aTypeError). Thanks to @SIENSIS for the report. Also fixes an operator-precedence bug in the status check and a missingregionfield in$allowedFields. - Auto-updater could hang indefinitely — the cURL client had no transfer timeout (CI4 default: 150 s connect, unlimited transfer); now capped at 15 s transfer / 5 s connect.
- Auto-updater 500 on network failure —
fetchAllChangedFiles()now runs inside thetry/catchand returns a structured error instead of throwing an uncaughtHTTPException. - Auto-updater silent partial update — the changed-file list is now carried from the same compare result instead of falling back to an empty list (also removes a redundant GitHub round-trip).
TypeErrorguards added toTranslations::import()andModuleInstaller::getModuleTables()forfile_get_contents()returningfalse.
🙏 Acknowledgements
Special thanks to @SIENSIS for reporting the login crash that motivated this release's privacy-first geo lookup rework.
Full changelog: https://github.com/ci4-cms-erp/ci4ms/blob/master/CHANGELOG.md
Previous release: 0.33.2.0
v0.33.2.0 — Fresh-install web installer fixes
This patch release fixes two functional bugs in the web installer (/install)
that could break a from-scratch installation. There are no new features and no
breaking changes. Existing installations are unaffected — this only matters when
running the browser-based installer on a fresh environment.
Fixed
- Web installer silently aborting on a fresh install. Because
.envis
written inside the same request that runs the installation, the boot-time
Config\DatabaseandConfig\Encryptionsingletons kept empty values. This
caused migrations to run against an empty database name (SQL syntax error on
SHOW TABLES FROM) andInstallService::createDefaultData()to fail with
Encrypter needs a starter key. The installer now rebinds thedefault
database group with the submitted credentials before migrating, and writes the
generated key back into the live encryption config for the current request. - Fatal error on install nonce mismatch. The nonce-mismatch path used the
invalidredirect()->route_to('install')and crashed instead of returning to
the form; corrected toredirect()->route('install').
Install bug reported by SIENSIS — thank you!
Upgrade notes
No action required for existing installs. If you previously hit a stalled or
failing fresh install, upgrade to this release and retry the web installer.
Full Changelog: 0.33.1.0...0.33.2.0
v0.33.1.0
A security-only release that closes three residual Stored XSS vectors, adds an
account-takeover defense-in-depth check to the profile password flow, and refactors
the Media module's elFinder access control for defense-in-depth. No new features and
no breaking changes — upgrading is recommended for all installations.
Security
- Stored XSS — Blog Categories Cover Image URL (
pageimg): Residual instance of the
"Pages Cover Image URL" XSS class (closed in 0.33.0.0). Input validation now enforces a
strict image-URL regex (onlyhttp(s)://or/-relative URLs ending in a known image
extension; rejects",=, whitespace,()) via a newcoverImageRules()helper, and
the cover-imageoutput is now escaped. Legacycreate.php/update.php
category views were removed (consolidated intoform.php). - Stored XSS — Frontend Blog Tag Template (
tags.php): Persisted SEOdescriptionis
now!empty()-guarded and escaped; posttitleinsideand the tag name in
the pageare now escaped. - Stored XSS — Frontend Blog Category Listing Header (
list.php): Category title in the
listing header is now escaped. - Account Takeover — Defense in Depth (Profile Password Change): Changing the password
onbackend/users/profilenow requires the current password, verified via Shield's
service('passwords')->verify(); a mismatch leaves the password unchanged. - Media Module — Defense-in-Depth Refactor (elFinder Access Control): elFinder write
commands are now a single-source-of-truthWRITE_COMMANDSconstant shared by the
controller 403 gate and elFinder's disabled list; a unit-testableisWriteBlocked()helper
centralizes the decision; connectordebugis now disabled in production.
Upgrade Notes
- No database migrations and no breaking API changes.
- New language keys
Users.currentPasswordandUsers.currentPasswordWrongare shipped for
both English and Turkish; custom/overridden language packs should add them. - Run
php spark cache:clearafter deploying as a routine precaution.
Residual Blog Categories cover-image XSS relates to the class originally reported by
iltosec.
Full Changelog: 0.32.0.0...0.33.1.0
v0.32.0.0
This release introduces an inline page status toggle, strengthens user management security, completes the Menu module's internationalization, and upgrades elFinder to 2.1.67.
✨ Added
- Pages: Inline Status Toggle — Activate or deactivate pages directly from the listing via a new
isActive()AJAX endpoint. Deactivating a page automatically removes it from the navigation menu and flushes the per-locale menu cache. - Pages: Homepage Badge — The pages DataTables listing now displays a visual "Home" badge on the current homepage, updating in real time when the selection changes.
- Users: Superadmin Delete Protection —
user_del()now requiressuperadminprivileges and prevents deletion of anysuperadminuser, returning a localized error message. - Menu Module: Full i18n — All hardcoded Turkish strings in views and JavaScript have been replaced with
lang()calls backed by new EN/TR language keys. - Sitemap Stylesheet — New
public/sitemap.cssprovides a clean, browser-friendly layout for the XML sitemap.
🔄 Changed
- elFinder → 2.1.67 — JS, CSS, and all i18n files updated. Three new help files added (
fr,zh_CN,zh_TW). Script tags include?v=2.1.67cache-busters. - elFinder CSRF Bypass — Internal CSRF validation disabled via anonymous class override since CI4 Shield's session auth and
backendGuardalready protect the connector. - Frontend: Inactive Pages Hidden —
Homecontroller now enforcesisActive = 1when resolving pages, preventing deactivated content from appearing on the public site. - Sitemap: Single-Language Mode —
BlogModelandPagesModelsitemap methods now respectApp.siteLanguageMode, emitting only default-locale records in single-language configurations. - Users: CSRF Exemptions — AJAX endpoints (
removeFromBlacklist,blackList,forceResetPassword,user_del) added toUsersConfig::$csrfExcept. - Backup AJAX Flow — Create/delete operations use proper
.done()/.fail()/.always()promise chains with deferred DataTables reload. - Menu:
refreshLeftList()→ GET — Read-only sidebar refresh no longer sends a POST request, eliminating unnecessary CSRF token injection. - Filters.php Simplification — Template filter path resolved via simple concatenation instead of
resolve_template_path().
🐛 Fixed
- Users: DataTables Search — Removed erroneous
$like = []reassignment that silently discarded search input. - Pages: Stale Homepage Badge —
homePageIdJavaScript variable now updates immediately after toggling the homepage via AJAX. - Backup & Users Views — DataTable instance variable moved to module scope so external handlers can call
table.ajax.reload()withoutReferenceError.
📋 Upgrade Notes
- No database migrations required for this release.
- If you have customized
modules/Menu/Views/menu.phpormodules/Menu/Language/*/Menu.php, merge the newlang()keys manually. - elFinder assets are vendored; no manual download needed — the staged files include the complete 2.1.67 bundle.
- Clear your cache after upgrading:
php spark cache:clear.
Full Changelog: 0.31.11.0...0.32.0.0
v0.31.11.0 — Installer Recovery & Hardening
This release fixes two installation-blocking regressions reported by the community: the web installer returned 404 after step 1, and the CLI installer aborted on migration. Both installation paths are now functional on every supported MySQL/MariaDB version, and the installer's HTTP attack surface has been reduced.
Highlights
- Web installer recovered — no more
404: GET install/dbsetupafter submitting the install form - CLI installer recovered —
php spark ci4ms:setupnow runs migrations successfully on MySQL 5.7+, MariaDB 10.x, and strict-mode installs - Installer attack surface reduced —
dbsetupis no longer a public endpoint
Fixed
- CRITICAL — Web Installer Broken (
404 GET install/dbsetup):Install::index()redirected toinstall/dbsetupvia HTTP 302 (which the browser follows withGET), but the route was registered asPOST-only. Every fresh web installation aborted with404 — Can't find a route for 'GET: install/dbsetup'. The two-step flow also relied on flashdata that could be lost across the redirect on some session drivers.index()now callsdbsetup($installData)directly in the same request, and theinstall/dbsetuproute has been removed. - CRITICAL — CLI Migration Failure (
profileIMG can't have a default value): Theuserstable migration declaredprofileIMGasTEXT NOT NULLwith a stringdefault. MySQL/MariaDB reject this withBLOB, TEXT, GEOMETRY or JSON column 'profileIMG' can't have a default valueon every server version that does not silently relax the rule.php spark ci4ms:setupaborted at Step 5/6 before the database was usable. Changed toVARCHAR(255) NULLso the default URL is preserved and the migration succeeds everywhere.
Changed
- Install Controller Hardening:
Install::dbsetup()is nowprivateand accepts the installation payload as a typedarrayparameter, removing the externally callable seed endpoint, the flashdata round-trip, and the empty-payload guard. Theinstall_dbsetuproute alias and itsrole=createpermission are gone, shrinking the installer's attack surface to a single endpoint protected byInstallFilter. - Version Bump:
app.versionnow defaults to0.31.11.0in bothInstall::index()andCi4msSetup::run().
Upgrade Notes
- Existing installations: No action required. This release only affects the installer (
/installandphp spark ci4ms:setup). Existingusers.profileIMGcolumns are not migrated. - Fresh installations: Both the web installer (
/install) and the CLI installer (php spark ci4ms:setup) now complete successfully on any MySQL 5.7+ or MariaDB 10.x server, regardless ofsql_mode. - Anyone who started a failed install: Drop the partially created database, remove any
.envandwritable/install.lockthat were generated, and re-run the installer.
Full Changelog: 0.31.10.0...0.31.11.0
v0.31.10.0 — Critical Module Deletion Fix
This release ships a critical data-loss fix in the module uninstall pipeline along with sitemap correctness fixes, CSRF token-sync repairs, and several frontend cleanups. All ci4ms deployments should upgrade — prior versions would wipe the entire database when uninstalling a single module due to a misuse of CodeIgniter 4's MigrationRunner::regress() API.
⚠️ Critical
- Single Module Deletion Wiped Entire Database.
ModuleInstaller::rollbackModuleMigrations()calledMigrationRunner::regress(0)aftersetNamespace(), expecting namespace-scoped rollback. CI4'sregress()nulls$this->namespaceinternally and walks the full migration history, so uninstalling any module would down every registered module's migrations and drop the entire database. The rollback path now iterates only the target module's namespace history and callsforce()per migration in reverse order, with afinallyblock that resets the shared runner singleton.
🛠️ Fixed
- Sitemap URL Duplication.
BlogModelandPagesModelsitemap entries returned fully-qualified URLs viasite_url()whileci4seopro\SitemapBuilderprependsbaseUrl, producing malformedvalues. Models now return path-onlyhttps://hosthttps://host/... locvalues, matching the package contract. - Sitemap Multilingual Coverage. Both sitemap models now
LEFT JOINtheir*_langstables so localized records are included instead of being filtered out by the primary-table-only query. - Backend CSRF Hidden Input Stale.
setCsrfHash()now syncs everycsrf_field()hidden input on the page after token regeneration; non-AJAX form submissions following an AJAX request no longer hit403 Forbidden. - Backend CSRF Empty-Body POSTs.
ajaxPrefilterwrites a pre-encoded URL string for empty POST bodies instead of building an object that jQuery would later re-serialize back to empty, stripping the token from the request. - Frontend Captcha Auto-Fire.
captchaF()no longer issues aPOST /commentCaptchaon every public page load; the bootstrap is now gated on the presence of.captchamarkup. - Methods Update View — Broken Route. Back-to-list link now resolves to the correct
methodListroute alias (waslist, which does not exist). - Methods Update View — Checkbox Active State.
inNavigation,isBackoffice, andhasChildflags now render correctly checked for existing records; added(bool)casts to compare integer storage (1/0) against strict boolean equality.
♻️ Changed
- elFinder Dialog Reuse.
pageImgelfinderDialog()andpageMultipleImgelfinderDialog()cache and reopen their existing jQuery wrapper instead of rebuilding the dialog on every invocation. Eliminates leaked event handlers, redundantcssAutoLoadHTTP requests, and the per-secondsyncexception when polling a destroyed instance (now wrapped intry/catch). - Captcha Refresh Trigger. Refresh button migrated from inline
onclick="captchaF()"to a.captcha-refreshclass bound via the existing delegated handler insidecaptchaF()— cleaner markup/behavior separation.
➕ Added
.gitignoreEntries.CLAUDE.mdandci4ms-specs/are now excluded so per-developer agent tooling artifacts stay out of version control.
⬆️ Upgrade Notes
- Pull the new release; no schema migration is required for the framework itself.
- Run
php spark cache:clearafter deploying so the cached settings, menu, and permission entries pick up the newapp.version. - The
app.versionvalue in your existing.envis not rewritten automatically — bump it to0.31.10.0manually if you rely on that key (the installer andci4ms:setupCLI command already use the new value for fresh installations). - Before uninstalling any module on a pre-0.31.10.0 deployment, take a full database backup — the legacy code path is destructive. After upgrading, module uninstall is safe and properly scoped.
- If you maintain a fork or downstream module pipeline that touched
MigrationRunner::regress(0), audit it for the same antipattern — the framework method does not honorsetNamespace().
Full changelog: 0.31.9.0...0.31.10.0
v0.31.9.0 — Security Hardening & Patch Release
Release date: 2026-05-08
Type: Security patch
Severity: Critical → High
This release closes ten security vulnerabilities across the authentication layer, file editor, content management modules, and the backup subsystem. It also hardens the CSRF architecture, eliminates all raw $_SERVER reads, and removes plaintext developer credentials. Three of these findings were responsibly disclosed by security researcher @offset and are documented in full below.
🔒 Security Fixes
Reported by @offset
-
[CRITICAL] Stored XSS — Blog Content (
html_purifybypass)
Thehtml_purifycustom validation rule was applied to the Blog content field butCustomRules::getClean()sanitized output was not persisted duringupdateoperations. An authenticated author could inject and store arbitrary JavaScript. Fixed by enforcinggetClean()output persistence on bothcreateandupdateflows inBlog.php. -
[CRITICAL] Stored XSS — Pages Content (
html_purifybypass)
Identical bypass in the Pages module: validation ran but the raw, unsanitized value was written to the database on update. Fixed by enforcingCustomRules::getClean()output persistence inPages.phpfor both creation and editing endpoints. -
[HIGH] Fileeditor — Destructive Operations Extension Allowlist Missing
The dangerous-extension blacklist was enforced oncreateFile,saveFile, andrenameFile(write paths) but not ondeleteFileOrFolderand the rename target validation. An authenticated user with file-editor access could rename or delete critical application files (e.g..env,composer.json,.htaccess). Fixed by adding an explicit extension allowlist check to all destructive operations.
Additional Hardening
-
CSRF Architecture Overhaul — Centralized
ajaxPrefilterinci4ms.jsfor automatic CSRF token injection on all AJAX requests. elFinder route exempted viaMediaConfig::$csrfExceptto prevent stale-token 403 errors during multi-request sessions. -
HTMLPurifier Hardening — Removed
data:URI scheme fromAllowedSchemes(blocksdata:text/html;base64XSS bypass). DisabledCSS.Trusted. EnabledHTML.TargetBlankfor automaticrel="noopener noreferrer". Blog and Pages controllers now always persistgetClean()output. -
IP Spoofing Fix — Removed raw
$_SERVER['HTTP_X_FORWARDED_FOR']/HTTP_CLIENT_IPreads fromBackendLogFilter; replaced with CI4's$request->getIPAddress()which respectsApp.proxyIPs. -
Raw
$_SERVERElimination — All$_SERVER['HTTP_HOST'],$_SERVER['HTTPS'],$_SERVER['SERVER_NAME']reads replaced with CI4base_url(),site_url(),parse_url()helpers acrossEmail.php,Ci4ms.php,Install.php, andSettings.php. -
Fileeditor RCE Prevention —
$dangerousExtensionsblacklist added tocreateFile/saveFile/renameFile.file_exists()overwrite protection added forcreateFile;realpathboundary validation added forrenameFile. -
SQL Restore Hardening — SQL statement whitelist (
INSERT,CREATE TABLE,DROP TABLE, …) and dangerous command blacklist (LOAD_FILE,INTO OUTFILE,GRANT,xp_cmdshell, …) implemented inDbBackup::restore(). Path traversal protection added — backup files must reside withinWRITEPATH. -
Hardcoded Credentials Removed — Plaintext passwords removed from
DevGateconfiguration.bcrypthashed passwords implemented;$useHashedPasswordsenabled by default.
🔧 Changed
- DevGate CLI Sync —
php spark ci4ms:setupnow automatically updatesDevGate.phpwith admin credentials provided at installation. - Proxy Configuration — Added Cloudflare and Nginx reverse proxy configuration examples as comments in
App.php::$proxyIPs. - URI Schemes — Removed unused
nntpandnewsURI schemes from HTMLPurifier configuration.
⬆️ Upgrade Notes
No database migrations are required for this release.
- Pull or download the new files.
- Clear application cache:
php spark cache:clear - If you have customized
Fileeditor.php, review the updateddeleteFileOrFolderandrenameFilemethods to ensure your changes are compatible with the new extension allowlist. - If you use a reverse proxy (Cloudflare, Nginx), configure
App.php::$proxyIPsto enable trusted IP detection.
🏆 Credits
Special thanks to @offset for responsibly disclosing three vulnerabilities (Stored XSS × 2, Fileeditor destructive operations bypass) that are patched in this release.
Found a vulnerability? Please report it via our Security Policy.
Full Changelog: 0.31.8.0...0.31.9.0
Release v0.31.8.0 - Security Patches
This release addresses two critical security vulnerabilities identified in the session management and theme management modules.
🛡️ Security Fixes
- Session Management Bypass: Fixed an issue where deactivated users could maintain active sessions. The system now performs account status verification on every request via
Ci4MsAuthFilter. - Arbitrary Database Table Drop: Fixed a vulnerability in the Theme module that allowed users with theme deletion permissions to drop any database table. A migration-based whitelist has been implemented to restrict table deletion exclusively to those belonging to the specific theme.
⚙️ Changes
- Bumps application version to
0.31.8.0across CLI and Web installers. - Updates
.gitignoreto refine module inclusion/exclusion rules.
Note: It is highly recommended to upgrade to this version immediately to ensure account management security and database integrity.
Full Changelog: 0.31.7.0...0.31.8.0