diff --git a/CHANGELOG.md b/CHANGELOG.md index 8523f56..275c44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ All notable public-release changes are recorded here. Historical project notes r - Updated database connection construction to honor `DB_PORT` while retaining port `3306` as the default. - Updated the root landing page to trigger the installation guard before normal output. +### Fixed + +- Enforced the installation lock for same-session requests to installer steps 1-8 after successful installation. +- Restricted the completion screen to one pending view and cleared completion-session state before the admin-login redirect. + ### Compatibility - No database table, column, index, foreign key, trigger, or migration was changed. diff --git a/RELEASE_NOTES_v5.1.0.md b/RELEASE_NOTES_v5.1.0.md index 7483677..04a0f14 100644 --- a/RELEASE_NOTES_v5.1.0.md +++ b/RELEASE_NOTES_v5.1.0.md @@ -91,6 +91,8 @@ After successful installation Licora creates: The installation flag contains only product, version, and installation timestamp. Installer files remain on disk but execution is disabled. +The completion screen is a one-time same-session handoff. After installation, requests to installer steps 1-8 are denied even when the browser still holds the installer session. Step 10 clears the completion-session state before redirecting to the administrator login. + ## Optional DEMO data When selected, the installer creates clearly marked DEMO data using existing tables only: diff --git a/includes/installation.php b/includes/installation.php index 43d274a..2ef47c0 100644 --- a/includes/installation.php +++ b/includes/installation.php @@ -782,3 +782,34 @@ function licora_installer_finalize(?string $root, array $data): array } } } + +if (!function_exists('licora_installer_locked_request_action')) { + /** + * Decide how an installer request is handled after installation. + * + * Only the one-time success handoff (step 9) and its subsequent login + * redirect (step 10) may proceed. Every other installed-state request, + * including same-session requests to steps 1-8, remains locked. + */ + function licora_installer_locked_request_action( + bool $locked, + int $step, + bool $hasSuccessData, + bool $successViewPending, + bool $loginRedirectPending + ): string { + if (!$locked) { + return 'continue'; + } + + if ($step === 9 && $hasSuccessData && $successViewPending) { + return 'show_success'; + } + + if ($step === 10 && $hasSuccessData && $loginRedirectPending) { + return 'redirect_login'; + } + + return 'locked'; + } +} diff --git a/install.php b/install.php index 649d897..41de91c 100644 --- a/install.php +++ b/install.php @@ -43,8 +43,36 @@ function installer_render_locked(): void exit; } +$requestedStep = filter_input(INPUT_GET, 'step', FILTER_VALIDATE_INT); +$step = $requestedStep !== false && $requestedStep !== null ? max(1, min(10, (int)$requestedStep)) : 1; $successData = $_SESSION['licora_installer_success'] ?? null; -if (licora_installer_is_locked($root) && !is_array($successData)) { +$lockedAction = licora_installer_locked_request_action( + licora_installer_is_locked($root), + $step, + is_array($successData), + !empty($_SESSION['licora_installer_success_view_pending']), + !empty($_SESSION['licora_installer_login_redirect_pending']) +); + +if ($lockedAction === 'show_success') { + unset($_SESSION['licora_installer_success_view_pending']); + $_SESSION['licora_installer_login_redirect_pending'] = true; +} elseif ($lockedAction === 'redirect_login') { + $adminUrl = (string)($successData['admin_url'] ?? ''); + unset( + $_SESSION['licora_installer_success'], + $_SESSION['licora_installer_success_view_pending'], + $_SESSION['licora_installer_login_redirect_pending'], + $_SESSION['licora_installer'], + $_SESSION['licora_installer_csrf'] + ); + session_regenerate_id(true); + if ($adminUrl === '' || preg_match('/[\r\n]/', $adminUrl) === 1) { + installer_render_locked(); + } + header('Location: ' . $adminUrl, true, 302); + exit; +} elseif ($lockedAction === 'locked') { installer_render_locked(); } @@ -56,19 +84,12 @@ function installer_render_locked(): void } $wizard =& $_SESSION['licora_installer']; -$requestedStep = filter_input(INPUT_GET, 'step', FILTER_VALIDATE_INT); -$step = $requestedStep !== false && $requestedStep !== null ? max(1, min(10, (int)$requestedStep)) : 1; -if (is_array($successData)) { - $wizard['max_step'] = 10; +if (is_array($successData) && $step === 9) { + $wizard['max_step'] = 9; } elseif ($step > (int)($wizard['max_step'] ?? 1)) { $step = (int)($wizard['max_step'] ?? 1); } -if ($step === 10 && is_array($successData)) { - header('Location: ' . (string)$successData['admin_url'], true, 302); - exit; -} - $error = ''; $notice = ''; $webBasePath = licora_installation_base_path(); @@ -194,7 +215,11 @@ function installer_render_locked(): void } else { $result = licora_installer_finalize($root, $wizard['data']); $_SESSION['licora_installer_success'] = $result; - unset($_SESSION['licora_installer']); + $_SESSION['licora_installer_success_view_pending'] = true; + unset( + $_SESSION['licora_installer_login_redirect_pending'], + $_SESSION['licora_installer'] + ); $_SESSION['licora_installer_csrf'] = bin2hex(random_bytes(32)); session_regenerate_id(true); header('Location: ?step=9'); diff --git a/tests/installer_smoke.php b/tests/installer_smoke.php index 278993a..f6a34f6 100644 --- a/tests/installer_smoke.php +++ b/tests/installer_smoke.php @@ -91,6 +91,36 @@ $assert(($flag['product'] ?? '') === 'Licora', 'installation flag identifies Licora'); $assert(($flag['version'] ?? '') === '5.1.0', 'installation flag records version'); $assert(!isset($flag['database_password']) && !isset($flag['encryption_key']), 'installation flag contains no secrets'); + +$assert( + licora_installer_locked_request_action(false, 1, false, false, false) === 'continue', + 'fresh installer requests continue' +); +$assert( + licora_installer_locked_request_action(true, 1, true, true, false) === 'locked', + 'same-session root revisit remains locked after installation' +); +$assert( + licora_installer_locked_request_action(true, 2, true, true, false) === 'locked', + 'same-session wizard steps remain locked after installation' +); +$assert( + licora_installer_locked_request_action(true, 9, true, true, false) === 'show_success', + 'one pending completion request may show the success screen' +); +$assert( + licora_installer_locked_request_action(true, 9, true, false, true) === 'locked', + 'success screen cannot be replayed after its pending view is consumed' +); +$assert( + licora_installer_locked_request_action(true, 10, true, false, true) === 'redirect_login', + 'pending completion flow may redirect to admin login' +); +$assert( + licora_installer_locked_request_action(true, 10, true, false, false) === 'locked', + 'direct step-ten access without the completion handoff remains locked' +); + @unlink($tempRoot . '/includes/.licora-installed'); @rmdir($tempRoot . '/includes'); @rmdir($tempRoot); @@ -102,9 +132,15 @@ 'Installation already completed.', 'Complete Installation', 'Go to Login', + 'licora_installer_success_view_pending', + 'licora_installer_login_redirect_pending', ] as $marker) { $assert(strpos($installer, $marker) !== false, 'installer marker present: ' . $marker); } +$assert( + strpos($installer, '&& !is_array($successData)') === false, + 'success-session data no longer bypasses the installation lock' +); if ($failures !== []) { fwrite(STDERR, "Installer smoke test failed:\n- " . implode("\n- ", $failures) . "\n");