|
7 | 7 |
|
8 | 8 | class Install extends Controller |
9 | 9 | { |
| 10 | + /** Cookie name carrying the per-installer-session nonce. */ |
| 11 | + private const INSTALL_NONCE_COOKIE = 'install_nonce'; |
| 12 | + |
10 | 13 | public function index() |
11 | 14 | { |
12 | 15 | 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 | + |
13 | 28 | $valData = [ |
14 | 29 | 'baseUrl' => ['label' => lang('Install.baseUrl'), 'rules' => 'required|valid_url'], |
15 | 30 | 'host' => ['label' => lang('Install.databaseHost'), 'rules' => 'required|max_length[255]|regex_match[/^[a-zA-Z0-9._-]+$/]'], |
@@ -94,7 +109,30 @@ public function index() |
94 | 109 |
|
95 | 110 | return $this->dbsetup($installData); |
96 | 111 | } |
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]); |
98 | 136 | } |
99 | 137 |
|
100 | 138 | private function updateEnvSettings(array $updates) |
|
0 commit comments