Skip to content

Releases: ci4-cms-erp/ci4ms

0.35.0.0 — Signed Updates (Ed25519), In-App Notifications with Realtime SSE, and a Broad Security Hardening Pass

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 28 Jul 22:13

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 with sodium_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 NotificationMessage DTO 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 passed JSON_UNESCAPED_UNICODE as $associative (position 2); worked by accident. Now passes true explicitly. (Reported by @0xAlchemist)
  • Sitemap joined on wrong column — pages_langs.id instead of pages_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 for checkTags() (data-loss scenario: pivot deletion before foreach(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_name and filenames rendered unescaped into SweetAlert2's html: 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 — CommonModel bypassed CI4's $defaultGroup = 'tests' override by requesting group 'default' by name. Observed: 8 duplicate auth_groups rows 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 to api.github.com.
  • High — Signed-but-hollow update — Crafted compare response could mark all files as removed, apply zero changes, bump .env version, and permanently pin the installation. Closed with removed_but_signed / empty_apply_set / conditional version write.
  • High — Path traversal in rollbackUpdate() — backup_name POST concatenated into filesystem path. ../../public and ../tmp both resolved. Now uses basename() + hash_equals() match against real listing.
  • High — Stored XSS via theme info.xml and backup directory names — 3 more instances of the same unescaped-into-markup pattern. All closed with esc() + escHtml() + addEventListener replacing inline onclick.
  • High — Media upload MIME allowlist only stopped .php — .phtml, .php5, .pht, .phar, .php.jpg all accepted. Closed with DENIED_EXTENSIONS checking 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_at with 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

  1. A real tests database group is now mandatory — configure database.tests.* in .env (schema name ending in _test, never the live DB).
  2. Auto-update is disabled by default — add your Ed25519 public key to UpdateKeys.php to enable signed updates.
  3. SSE realtime is off by default — set notificationsconfig.realtimeEnabled = true and register the RealtimeController route permission via Methods module scan before enabling.

📦 Upgrade

# Standard upgrade path
composer update
php spark migrate --all
php spark cache:clear

or you can

php spark ci4ms:migrate

Full Changelog: 0.34.1.0...0.35.0.0

0.34.1.0

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 25 Jul 22:35

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:

  1. php spark migrate --all (or php spark migrate -n "Modules\Notifications")
  2. Backend → Methods / Modules → Module Scan, then php spark cache:clear
  3. 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 403 for 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 requires ext-redis and a pm.max_children sized for your concurrent admin count — each open stream holds a PHP-FPM worker for up to realtimeStreamTtl seconds. 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 separate notification_reads table. 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 own user rows, 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 Lua EVAL over 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. critical notifications cannot be muted.
    • Admin composer. A "Send notification" screen with server-validated audience selection, a recipient-count preview, and a created_by accountability column recording who published each notification.
    • Ships a php spark notifications:purge command for retention (no cron is installed — schedule it yourself) and php spark notifications:test for verifying an installation.
  • 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-wide cache: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_id in relevance joins (defense in depth). Notifier binds $userId through db->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. The settings cache 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::$jsonEncodeDepth added. CI 4.7.4's JSONFormatter reads 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_UNICODE into json_decode()'s $associative parameter 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 stored maintenanceMode = "0" could also evaluate as true, forcing the site into maintenance while it was actually off.
  • PHP 8 Cannot use object of type stdClass as array fatals in settings-driven frontend views (fonts, theme assets, widgets, footer links), plus socialNetwork rendering 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 migrate aborting with Duplicate 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_read column 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

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 05 Jul 06:38

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 runs php spark ci4ms:geoip-update to 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\GeoLocator reads the MMDB file via maxmind-db/reader.
    • New php spark ci4ms:geoip-update command 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.geoLookupEnabled setting (default false), 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/reader dependency and new English/Turkish language keys.
    • DB-IP attribution (CC BY 4.0) is surfaced in the README, the installer, and the command output.

🔁 Changed

  • Session geo lookup replaces the previous inline ip-api.com HTTP 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.com unreachable — json_decode(file_get_contents(...)) returning false raised an uncaught TypeError under strict_types, turning every login into a 500 (the @ operator does not suppress a TypeError). Thanks to @SIENSIS for the report. Also fixes an operator-precedence bug in the status check and a missing region field 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 the try/catch and returns a structured error instead of throwing an uncaught HTTPException.
  • 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).
  • TypeError guards added to Translations::import() and ModuleInstaller::getModuleTables() for file_get_contents() returning false.

🙏 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

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 02 Jul 21:22

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 .env is
    written inside the same request that runs the installation, the boot-time
    Config\Database and Config\Encryption singletons kept empty values. This
    caused migrations to run against an empty database name (SQL syntax error on
    SHOW TABLES FROM ) and InstallService::createDefaultData() to fail with
    Encrypter needs a starter key. The installer now rebinds the default
    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
    invalid redirect()->route_to('install') and crashed instead of returning to
    the form; corrected to redirect()->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

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 02 Jul 16:31

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 (only http(s):// or /-relative URLs ending in a known image
    extension; rejects ", =, whitespace, ()) via a new coverImageRules() helper, and
    the cover-image output is now escaped. Legacy create.php/update.php
    category views were removed (consolidated into form.php).
  • Stored XSS — Frontend Blog Tag Template (tags.php): Persisted SEO description is
    now !empty()-guarded and escaped; post title inside and the tag name in
    the page

    are 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
    on backend/users/profile now 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-truth WRITE_COMMANDS constant shared by the
    controller 403 gate and elFinder's disabled list; a unit-testable isWriteBlocked() helper
    centralizes the decision; connector debug is now disabled in production.

Upgrade Notes

  • No database migrations and no breaking API changes.
  • New language keys Users.currentPassword and Users.currentPasswordWrong are shipped for
    both English and Turkish; custom/overridden language packs should add them.
  • Run php spark cache:clear after 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

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 02 Jun 21:57

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 requires superadmin privileges and prevents deletion of any superadmin user, 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.css provides 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.67 cache-busters.
  • elFinder CSRF Bypass — Internal CSRF validation disabled via anonymous class override since CI4 Shield's session auth and backendGuard already protect the connector.
  • Frontend: Inactive Pages Hidden — Home controller now enforces isActive = 1 when resolving pages, preventing deactivated content from appearing on the public site.
  • Sitemap: Single-Language Mode — BlogModel and PagesModel sitemap methods now respect App.siteLanguageMode, emitting only default-locale records in single-language configurations.
  • Users: CSRF Exemptions — AJAX endpoints (removeFromBlacklist, blackList, forceResetPassword, user_del) added to UsersConfig::$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 — homePageId JavaScript 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() without ReferenceError.

📋 Upgrade Notes

  • No database migrations required for this release.
  • If you have customized modules/Menu/Views/menu.php or modules/Menu/Language/*/Menu.php, merge the new lang() 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

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 24 May 20:07

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/dbsetup after submitting the install form
  • CLI installer recovered — php spark ci4ms:setup now runs migrations successfully on MySQL 5.7+, MariaDB 10.x, and strict-mode installs
  • Installer attack surface reduced — dbsetup is no longer a public endpoint

Fixed

  • CRITICAL — Web Installer Broken (404 GET install/dbsetup): Install::index() redirected to install/dbsetup via HTTP 302 (which the browser follows with GET), but the route was registered as POST-only. Every fresh web installation aborted with 404 — 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 calls dbsetup($installData) directly in the same request, and the install/dbsetup route has been removed.
  • CRITICAL — CLI Migration Failure (profileIMG can't have a default value): The users table migration declared profileIMG as TEXT NOT NULL with a string default. MySQL/MariaDB reject this with BLOB, TEXT, GEOMETRY or JSON column 'profileIMG' can't have a default value on every server version that does not silently relax the rule. php spark ci4ms:setup aborted at Step 5/6 before the database was usable. Changed to VARCHAR(255) NULL so the default URL is preserved and the migration succeeds everywhere.

Changed

  • Install Controller Hardening: Install::dbsetup() is now private and accepts the installation payload as a typed array parameter, removing the externally callable seed endpoint, the flashdata round-trip, and the empty-payload guard. The install_dbsetup route alias and its role=create permission are gone, shrinking the installer's attack surface to a single endpoint protected by InstallFilter.
  • Version Bump: app.version now defaults to 0.31.11.0 in both Install::index() and Ci4msSetup::run().

Upgrade Notes

  • Existing installations: No action required. This release only affects the installer (/install and php spark ci4ms:setup). Existing users.profileIMG columns 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 of sql_mode.
  • Anyone who started a failed install: Drop the partially created database, remove any .env and writable/install.lock that 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

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 23 May 19:41

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() called MigrationRunner::regress(0) after setNamespace(), expecting namespace-scoped rollback. CI4's regress() nulls $this->namespace internally 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 calls force() per migration in reverse order, with a finally block that resets the shared runner singleton.

🛠️ Fixed

  • Sitemap URL Duplication. BlogModel and PagesModel sitemap entries returned fully-qualified URLs via site_url() while ci4seopro\SitemapBuilder prepends baseUrl, producing malformed https://hosthttps://host/... values. Models now return path-only loc values, matching the package contract.
  • Sitemap Multilingual Coverage. Both sitemap models now LEFT JOIN their *_langs tables so localized records are included instead of being filtered out by the primary-table-only query.
  • Backend CSRF Hidden Input Stale. setCsrfHash() now syncs every csrf_field() hidden input on the page after token regeneration; non-AJAX form submissions following an AJAX request no longer hit 403 Forbidden.
  • Backend CSRF Empty-Body POSTs. ajaxPrefilter writes 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 a POST /commentCaptcha on every public page load; the bootstrap is now gated on the presence of .captcha markup.
  • Methods Update View — Broken Route. Back-to-list link now resolves to the correct methodList route alias (was list, which does not exist).
  • Methods Update View — Checkbox Active State. inNavigation, isBackoffice, and hasChild flags 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() and pageMultipleImgelfinderDialog() cache and reopen their existing jQuery wrapper instead of rebuilding the dialog on every invocation. Eliminates leaked event handlers, redundant cssAutoLoad HTTP requests, and the per-second sync exception when polling a destroyed instance (now wrapped in try/catch).
  • Captcha Refresh Trigger. Refresh button migrated from inline onclick="captchaF()" to a .captcha-refresh class bound via the existing delegated handler inside captchaF() — cleaner markup/behavior separation.

➕ Added

  • .gitignore Entries. CLAUDE.md and ci4ms-specs/ are now excluded so per-developer agent tooling artifacts stay out of version control.

⬆️ Upgrade Notes

  1. Pull the new release; no schema migration is required for the framework itself.
  2. Run php spark cache:clear after deploying so the cached settings, menu, and permission entries pick up the new app.version.
  3. The app.version value in your existing .env is not rewritten automatically — bump it to 0.31.10.0 manually if you rely on that key (the installer and ci4ms:setup CLI command already use the new value for fresh installations).
  4. 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.
  5. 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 honor setNamespace().

Full changelog: 0.31.9.0...0.31.10.0

v0.31.9.0 — Security Hardening & Patch Release

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 08 May 00:08

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_purify bypass)
    The html_purify custom validation rule was applied to the Blog content field but CustomRules::getClean() sanitized output was not persisted during update operations. An authenticated author could inject and store arbitrary JavaScript. Fixed by enforcing getClean() output persistence on both create and update flows in Blog.php.

  • [CRITICAL] Stored XSS — Pages Content (html_purify bypass)
    Identical bypass in the Pages module: validation ran but the raw, unsanitized value was written to the database on update. Fixed by enforcing CustomRules::getClean() output persistence in Pages.php for both creation and editing endpoints.

  • [HIGH] Fileeditor — Destructive Operations Extension Allowlist Missing
    The dangerous-extension blacklist was enforced on createFile, saveFile, and renameFile (write paths) but not on deleteFileOrFolder and 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 ajaxPrefilter in ci4ms.js for automatic CSRF token injection on all AJAX requests. elFinder route exempted via MediaConfig::$csrfExcept to prevent stale-token 403 errors during multi-request sessions.

  • HTMLPurifier Hardening — Removed data: URI scheme from AllowedSchemes (blocks data:text/html;base64 XSS bypass). Disabled CSS.Trusted. Enabled HTML.TargetBlank for automatic rel="noopener noreferrer". Blog and Pages controllers now always persist getClean() output.

  • IP Spoofing Fix — Removed raw $_SERVER['HTTP_X_FORWARDED_FOR'] / HTTP_CLIENT_IP reads from BackendLogFilter; replaced with CI4's $request->getIPAddress() which respects App.proxyIPs.

  • Raw $_SERVER Elimination — All $_SERVER['HTTP_HOST'], $_SERVER['HTTPS'], $_SERVER['SERVER_NAME'] reads replaced with CI4 base_url(), site_url(), parse_url() helpers across Email.php, Ci4ms.php, Install.php, and Settings.php.

  • Fileeditor RCE Prevention — $dangerousExtensions blacklist added to createFile/saveFile/renameFile. file_exists() overwrite protection added for createFile; realpath boundary validation added for renameFile.

  • SQL Restore Hardening — SQL statement whitelist (INSERT, CREATE TABLE, DROP TABLE, …) and dangerous command blacklist (LOAD_FILE, INTO OUTFILE, GRANT, xp_cmdshell, …) implemented in DbBackup::restore(). Path traversal protection added — backup files must reside within WRITEPATH.

  • Hardcoded Credentials Removed — Plaintext passwords removed from DevGate configuration. bcrypt hashed passwords implemented; $useHashedPasswords enabled by default.


🔧 Changed

  • DevGate CLI Sync — php spark ci4ms:setup now automatically updates DevGate.php with 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 nntp and news URI schemes from HTMLPurifier configuration.

⬆️ Upgrade Notes

No database migrations are required for this release.

  1. Pull or download the new files.
  2. Clear application cache: php spark cache:clear
  3. If you have customized Fileeditor.php, review the updated deleteFileOrFolder and renameFile methods to ensure your changes are compatible with the new extension allowlist.
  4. If you use a reverse proxy (Cloudflare, Nginx), configure App.php::$proxyIPs to 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

Choose a tag to compare

@bertugfahriozer bertugfahriozer released this 19 Apr 01:04

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.0 across CLI and Web installers.
  • Updates .gitignore to 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