Skip to content

Commit 1d27a87

Browse files
security: bind install form to a SameSite cookie nonce
modules/Install/Config/InstallConfig.php exempts install/* from the session-bound CSRF check because the installer runs before any session or encryption key exists. Bulgu 3 already prevents the installer from being reached after .env exists; this commit closes the remaining window during initial install where an attacker page in another tab could POST to /install cross-origin and seed a malicious .env / admin. Mechanism (independent of CI4's session + encryption stack so it works on a fresh, pre-install machine): * GET /install: read or generate a 32-hex install_nonce cookie. Cookie attributes are HttpOnly + SameSite=Lax + Secure-when-HTTPS, so the browser refuses to send it on cross-origin form POSTs. The view echoes the value into a hidden input. * POST /install: read the cookie AND the hidden field, fail closed with a flash error if either is missing or they don't match (hash_equals for timing safety). Validation runs only after the nonce match. Multi-tab / page-reload UX is preserved: the same cookie value is returned to the view until it expires after 1h, so refreshing or opening in a second tab keeps working. If the user submits with an expired or missing nonce, they get a clear "reload and resubmit" flash error rather than a silent failure. Adds one translation key (Install.invalidNonce) for en + tr. Refs: ci4ms-security-audit Finding 16 (Low, residual after Finding 3). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4c94014 commit 1d27a87

4 files changed

Lines changed: 42 additions & 1 deletion

File tree

modules/Install/Controllers/Install.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,24 @@
77

88
class Install extends Controller
99
{
10+
/** Cookie name carrying the per-installer-session nonce. */
11+
private const INSTALL_NONCE_COOKIE = 'install_nonce';
12+
1013
public function index()
1114
{
1215
if ($this->request->is('post')) {
16+
// Pre-validate the install nonce. The shipped CSRF middleware is
17+
// disabled on install/* in InstallConfig (no session exists yet to
18+
// bind the standard token to), so we bind the form to a cookie
19+
// value the browser only sends back when the POST is same-site.
20+
// Combined with the SameSite=Lax default this neutralises the
21+
// pre-install CSRF window described in audit Finding 16.
22+
$cookieNonce = (string) ($this->request->getCookie(self::INSTALL_NONCE_COOKIE) ?? '');
23+
$postNonce = (string) ($this->request->getPost(self::INSTALL_NONCE_COOKIE) ?? '');
24+
if ($cookieNonce === '' || $postNonce === '' || !hash_equals($cookieNonce, $postNonce)) {
25+
return redirect()->back()->withInput()->with('errors', ['install' => lang('Install.invalidNonce')]);
26+
}
27+
1328
$valData = [
1429
'baseUrl' => ['label' => lang('Install.baseUrl'), 'rules' => 'required|valid_url'],
1530
'host' => ['label' => lang('Install.databaseHost'), 'rules' => 'required|max_length[255]|regex_match[/^[a-zA-Z0-9._-]+$/]'],
@@ -94,7 +109,30 @@ public function index()
94109

95110
return $this->dbsetup($installData);
96111
}
97-
return view('Modules\Install\Views\install');
112+
113+
// GET: ensure the browser holds an install_nonce cookie, generate one
114+
// if missing, and pass the value to the view so the form can echo it
115+
// back as a hidden field. Reusing an existing cookie avoids breaking
116+
// multi-tab / reload UX during the install flow.
117+
helper('cookie');
118+
$nonce = (string) ($this->request->getCookie(self::INSTALL_NONCE_COOKIE) ?? '');
119+
if ($nonce === '' || !preg_match('/^[a-f0-9]{32}$/', $nonce)) {
120+
$nonce = bin2hex(random_bytes(16));
121+
// Lax SameSite (CI4 default) and httpOnly: cross-origin POSTs
122+
// won't carry this cookie, so the hash_equals() above will fail
123+
// for any attacker-driven form submission.
124+
set_cookie([
125+
'name' => self::INSTALL_NONCE_COOKIE,
126+
'value' => $nonce,
127+
'expire' => 3600,
128+
'path' => '/',
129+
'secure' => $this->request->isSecure(),
130+
'httponly' => true,
131+
'samesite' => 'Lax',
132+
]);
133+
}
134+
135+
return view('Modules\Install\Views\install', ['installNonce' => $nonce]);
98136
}
99137

100138
private function updateEnvSettings(array $updates)

modules/Install/Language/en/Install.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,5 @@
4343
'lastName' => 'Last Name',
4444
'password' => 'Password',
4545
'foldersWithSameNameListItem' => 'Folders With Same Name List Item',
46+
'invalidNonce' => 'The install session is missing or expired. Reload this page and submit the form again.',
4647
];

modules/Install/Language/tr/Install.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,5 @@
4343
'lastName' => 'Soyadınız',
4444
'password' => 'Şifreniz',
4545
'foldersWithSameNameListItem' => 'Aynı İsimli Klasörler',
46+
'invalidNonce' => 'Yükleme oturumu eksik veya süresi dolmuş. Lütfen sayfayı yenileyip formu tekrar gönderin.',
4647
];

modules/Install/Views/install.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<?php echo view('Modules\Auth\Views\_message_block') ?>
1414
<form action="<?php echo route_to('install') ?>" method="post">
1515
<?php echo csrf_field() ?>
16+
<input type="hidden" name="install_nonce" value="<?php echo esc($installNonce ?? '', 'attr') ?>">
1617
<div class="bs-stepper">
1718
<div class="bs-stepper-header" role="tablist">
1819
<!-- your steps here -->

0 commit comments

Comments
 (0)