Skip to content

Commit 9bc8983

Browse files
feat: Security hardening, rate limiting, and custom error handling
This release focuses on significant security patches and new architectural features. Security Fixes: - Fixed broken access control: media "read" permission inadvertently granted full file write and delete access. - Fixed unsafe reflection in dashboard widget data_source which could disclose password hashes. - Prevented Remote Code Execution (RCE) via template-function parsing in page content. - Patched Stored XSS in Pages Cover Image URL that could lead to an admin account takeover. (All security issues above reported by @iltosec) Features & Enhancements: - Added `ThrottleFilter` and `BackendThrottleFilter` for global and backend rate limiting (HTTP 429). - Introduced `LockController` and an idle lock screen to automatically lock inactive administrative sessions. - Added `BackendMaintenanceFilter` and library for robust maintenance mode handling (HTTP 503). - Implemented `BackendExceptionHandler` for custom error views (403, 404, 429, 500, 503), replacing the legacy `Errors.php` controller. - Introduced `WidgetDataProviderInterface` to strictly enforce data sourcing contracts for Dashboard Widgets. - Consolidated `create.php` and `update.php` into a unified `form.php` structure across `Pages` and `Blog` modules. - Updated `SessionTracker` to store `locked_at` timestamp.
1 parent 87c3ff5 commit 9bc8983

103 files changed

Lines changed: 4170 additions & 1744 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) conventions adapted to the existing four-component version numbers.
66

7+
## [0.33.0.0] - 2026-06-17
8+
9+
### Security
10+
11+
- **Broken Access Control:** Fixed an issue where the Media module's "read" permission unintentionally granted full file write and delete capabilities.
12+
- **Unsafe Reflection:** Secured the Dashboard Widgets `data_source` execution to prevent arbitrary method invocation that could disclose sensitive data like password hashes.
13+
- **Remote Code Execution (RCE):** Prevented RCE vulnerabilities arising from unsafe template-function parsing within Page content.
14+
- **Stored XSS:** Patched a Stored Cross-Site Scripting (XSS) vulnerability via the Pages Cover Image URL that could lead to an admin account takeover.
15+
*(All security issues above reported by [iltosec](https://github.com/iltosec))*
16+
17+
### Added
18+
19+
- **Rate Limiting:** Introduced `ThrottleFilter` and `BackendThrottleFilter` to provide rate limiting (429 Too Many Requests) across the application.
20+
- **Maintenance Mode:** Added `BackendMaintenanceFilter` and `BackendMaintenance` library to elegantly handle 503 Service Unavailable scenarios.
21+
- **Custom Exception Handling:** Implemented `BackendExceptionHandler` for improved presentation of HTTP errors (403, 404, 429, 500, 503).
22+
- **Idle Lock Screen:** Added `LockController` and `lock.php` view, along with updates to `ci4ms.js`, to lock inactive administrative sessions.
23+
- **Widget Security:** Introduced `WidgetDataProviderInterface` to strictly enforce data sourcing contracts for Dashboard Widgets.
24+
25+
### Changed
26+
27+
- **Error Handling:** Removed legacy `Errors.php` controller in favor of the new `BackendExceptionHandler` system.
28+
- **Module Views Unified:** Consolidated `create.php` and `update.php` into a unified `form.php` structure across `Pages` and `Blog` modules for maintainability.
29+
- **Session Tracking:** Updated `SessionTracker` to track locked sessions with a `locked_at` timestamp.
30+
731
## [0.32.0.0] - 2026-06-03
832

933
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ A huge thank you to the security researchers who have helped make **ci4ms** more
213213
| **[fg0x0](https://github.com/fg0x0)** | Identified Critical Arbitrary File Write (Zip Slip RCE) vulnerabilities in Theme::upload and Backup::restore modules. | Apr 2026 |
214214
| **[0xAlchemist](https://github.com/bugmithlegend)** , **[peeefour](https://github.com/peeefour)** and **[DexterHK](https://github.com/DexterHK)** | Identified Critical Full Account Takeover and Privilege Escalation via Stored DOM Blind XSS in Backup Management (v2). | Apr 2026 |
215215
| **[dapickle](https://github.com/dapickle)** | Identified Critical Authenticated RCE in Theme installation, Arbitrary Database Table Drop in Theme module, and a Session Management Bypass. | Apr 2026 |
216+
| **[iltosec](https://github.com/iltosec)** | Identified Broken Access Control in Media module, Unsafe Reflection in Dashboard Widgets, RCE via template-function parsing in Pages, and Stored XSS in Pages Cover Image URL leading to Account Takeover. | Jun 2026 |
216217

217218
> If you find a security vulnerability, please report it via [Security Policy](SECURITY.md).
218219

app/Config/Exceptions.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,60 @@ class Exceptions extends BaseConfig
101101
*/
102102
public function handler(int $statusCode, Throwable $exception): ExceptionHandlerInterface
103103
{
104+
// Backend modülü bağlamında ise kendi handler'ımızı kullan
105+
if ($this->isBackendContext($exception)) {
106+
return new \Modules\Backend\Exceptions\BackendExceptionHandler($this);
107+
}
108+
104109
return new ExceptionHandler($this);
105110
}
111+
112+
/**
113+
* Checks whether the exception is within the Backend module context.
114+
*
115+
* 3-layer check:
116+
* 1. Is the active controller in the Backend namespace?
117+
* 2. Was the exception thrown from a Backend module file?
118+
* 3. Is there a Backend module class in the call stack?
119+
*/
120+
121+
private function isBackendContext(Throwable $exception): bool
122+
{
123+
// 1. Aktif controller Backend namespace'inde mi?
124+
try {
125+
$controller = service('router')->controllerName();
126+
127+
if ($controller && str_starts_with($controller, 'Modules\\Backend\\')) {
128+
return true;
129+
}
130+
} catch (\Throwable $e) {
131+
// Router henüz hazır değilse atla
132+
}
133+
134+
// 2. Exception Backend modülü içindeki bir dosyadan mı fırlatıldı?
135+
if (str_contains($exception->getFile(), 'modules' . DIRECTORY_SEPARATOR . 'Backend' . DIRECTORY_SEPARATOR)) {
136+
return true;
137+
}
138+
139+
// 3. Call stack'te Backend modülü sınıfı var mı?
140+
foreach ($exception->getTrace() as $frame) {
141+
if (!isset($frame['class'])) {
142+
continue;
143+
}
144+
// Doğrudan Backend namespace'inde mi?
145+
if (str_starts_with($frame['class'], 'Modules\\Backend\\')) {
146+
return true;
147+
}
148+
// BaseController'ı extend eden herhangi bir sınıf mı?
149+
// (Pages, Blog, Catalog vb. backend modülleri bunu karşılar)
150+
if (
151+
class_exists($frame['class'], false)
152+
&& is_subclass_of($frame['class'], 'Modules\\Backend\\Controllers\\BaseController')
153+
) {
154+
return true;
155+
}
156+
}
157+
158+
return false;
159+
}
106160
}

app/Config/Filters.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,20 @@ private function loadDynamicFilters(array $directories): void
183183
\CodeIgniter\Shield\Filters\SessionAuth::class,
184184
\CodeIgniter\Shield\Filters\ForcePasswordResetFilter::class,
185185
\Modules\Auth\Filters\Ci4MsAuthFilter::class,
186+
\Modules\Backend\Filters\BackendMaintenanceFilter::class,
186187
\Modules\Backend\Filters\BackendLogFilter::class,
187188
\Modules\Auth\Filters\SessionTracker::class,
188189
\Modules\Backend\Filters\CsrfTokenRefreshFilter::class,
190+
\Modules\Backend\Filters\BackendThrottleFilter::class,
189191
];
190192
$this->aliases['langfilter'] = [
191193
\App\Filters\Ci4ms::class,
192194
\Modules\LanguageManager\Filters\LocaleFilter::class,
193195
];
196+
197+
$this->aliases['throttle'] = \App\Filters\ThrottleFilter::class;
198+
$this->aliases['auth-rates'] = \Modules\Auth\Filters\AuthThrottleFilter::class;
199+
194200
foreach ($directories as $directory) {
195201
if (is_dir($directory)) {
196202
foreach (glob("$directory/*.php") as $file) {

app/Config/Throttle.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace Config;
4+
5+
use CodeIgniter\Config\BaseConfig;
6+
7+
/**
8+
* Rate-limit (throttle) profilleri.
9+
*
10+
* Her profil: [capacity, seconds] => "seconds saniyede capacity istek".
11+
* Route/gruba `throttle:profil` filtresi ile uygulanır (bkz. App\Filters\ThrottleFilter).
12+
* Modül/route bazında farklı limitler buradan yönetilir; yeni profil eklemek yeterli.
13+
*/
14+
class Throttle extends BaseConfig
15+
{
16+
/**
17+
* Profil verilmezse kullanılacak varsayılan.
18+
*/
19+
public string $default = 'web';
20+
21+
/**
22+
* profil => [capacity (istek sayısı), seconds (pencere/saniye)]
23+
*
24+
* @var array<string, array{0:int, 1:int}>
25+
*/
26+
public array $profiles = [
27+
'web' => [180, 60], // genel web
28+
'backend' => [300, 60], // yönetim paneli (AJAX yoğun)
29+
'api' => [100, 60], // ileride API
30+
'auth' => [10, 60], // login/register vb. (Shield ile aynı)
31+
'strict' => [20, 60], // hassas uçlar
32+
];
33+
34+
/**
35+
* Bu öneklerle başlayan path'ler için 429 yanıtı JSON döner (API istemcileri).
36+
*
37+
* @var list<string>
38+
*/
39+
public array $apiPrefixes = ['api'];
40+
}

app/Filters/Ci4ms.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class Ci4ms implements FilterInterface
1414

1515
public function __construct()
1616
{
17-
if (file_exists(ROOTPATH . '.env')) $this->commonModel = new CommonModel();
17+
if (file_exists(ROOTPATH . '.env'))
18+
$this->commonModel = new CommonModel();
1819
}
1920

2021
/**
@@ -35,9 +36,12 @@ public function __construct()
3536
public function before(RequestInterface $request, $arguments = null)
3637
{
3738
if (!file_exists(ROOTPATH . '.env')) {
38-
return redirect()->to(site_url('install'));
39+
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https://" : "http://";
40+
return redirect()->to($protocol . $_SERVER['SERVER_NAME'] . '/install');
41+
3942
}
40-
if ((bool)cache()->get('settings')['maintenanceMode']->scalar === true) return redirect()->route('maintenance-mode');
43+
if ((bool) cache()->get('settings')['maintenanceMode']->scalar === true)
44+
return redirect()->route('maintenance-mode');
4145
}
4246

4347
/**

app/Filters/ThrottleFilter.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace App\Filters;
4+
5+
use CodeIgniter\Filters\FilterInterface;
6+
use CodeIgniter\HTTP\IncomingRequest;
7+
use CodeIgniter\HTTP\RequestInterface;
8+
use CodeIgniter\HTTP\ResponseInterface;
9+
10+
/**
11+
* Genel amaçlı, profil tabanlı rate-limit filtresi.
12+
*
13+
* Kullanım (route / grup):
14+
* ['filter' => 'throttle:backend'] // Config\Throttle::$profiles['backend']
15+
* ['filter' => 'throttle:api']
16+
*
17+
* Limit aşılınca:
18+
* - Web isteği → markalı error_429 sayfası, sayaç GERÇEK kalan süreyle (Retry-After)
19+
* - API / AJAX → JSON { status:429, retry_after:N }
20+
* Her iki durumda da HTTP 429 + `Retry-After` header'ı set edilir.
21+
*/
22+
class ThrottleFilter implements FilterInterface
23+
{
24+
public function before(RequestInterface $request, $arguments = null)
25+
{
26+
// CLI / non-HTTP istekleri atla
27+
if (! $request instanceof IncomingRequest) {
28+
return;
29+
}
30+
31+
$config = config('Throttle');
32+
$profile = $arguments[0] ?? $config->default;
33+
[$capacity, $seconds] = $config->profiles[$profile] ?? $config->profiles[$config->default];
34+
35+
$throttler = service('throttler');
36+
$key = $this->buildKey($request, $profile);
37+
38+
if ($throttler->check($key, (int) $capacity, (int) $seconds) === false) {
39+
return $this->reject($request, $throttler->getTokenTime());
40+
}
41+
}
42+
43+
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
44+
{
45+
// no-op
46+
}
47+
48+
/**
49+
* Bucket anahtarı: profil + IP (+ giriş yapan kullanıcı).
50+
*/
51+
protected function buildKey(IncomingRequest $request, string $profile): string
52+
{
53+
$id = (function_exists('auth') && auth()->loggedIn()) ? (string) auth()->id() : 'guest';
54+
55+
return md5('throttle:' . $profile . ':' . $request->getIPAddress() . ':' . $id);
56+
}
57+
58+
/**
59+
* 429 yanıtını üret (içerik tipine göre HTML veya JSON).
60+
*/
61+
protected function reject(IncomingRequest $request, int $retryAfter): ResponseInterface
62+
{
63+
$response = service('response')
64+
->setStatusCode(429)
65+
->setHeader('Retry-After', (string) $retryAfter);
66+
67+
if ($this->wantsJson($request)) {
68+
return $response->setJSON([
69+
'status' => 429,
70+
'error' => 'Too Many Requests',
71+
'retry_after' => $retryAfter,
72+
]);
73+
}
74+
75+
return $response->setBody(
76+
view('Modules\Backend\Views\errors\html\error_429', ['retryAfter' => $retryAfter])
77+
);
78+
}
79+
80+
/**
81+
* İstek JSON mı bekliyor? (AJAX, Accept: application/json veya API path öneki)
82+
*/
83+
protected function wantsJson(IncomingRequest $request): bool
84+
{
85+
if ($request->isAJAX()) {
86+
return true;
87+
}
88+
89+
if (str_contains($request->getHeaderLine('Accept'), 'application/json')) {
90+
return true;
91+
}
92+
93+
$path = ltrim($request->getUri()->getPath(), '/');
94+
foreach ((array) config('Throttle')->apiPrefixes as $prefix) {
95+
$prefix = trim((string) $prefix, '/');
96+
if ($prefix !== '' && ($path === $prefix || str_starts_with($path, $prefix . '/'))) {
97+
return true;
98+
}
99+
}
100+
101+
return false;
102+
}
103+
}

0 commit comments

Comments
 (0)