From 476f688bd866dabd648f42140671df55b8523712 Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 18 Mar 2026 22:41:59 +0100 Subject: [PATCH] =?UTF-8?q?harden:=20web/index.php=20=E2=80=94=20runtime?= =?UTF-8?q?=20fingerprint,=20API=20channel=20isolation,=20extract()=20safe?= =?UTF-8?q?ty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three robustness improvements to the request entry point: P1a: Module runtime stale-detection via fingerprint - ModuleRuntimePageBuilder gains writeFingerprint/readFingerprint/expectedFingerprint - module-build.php writes storage/runtime/.module-fingerprint after each build - web/index.php validates fingerprint on every request; throws RuntimeException with actionable message if stale (replaces silent auto-build in request path) - Eliminates P2 (concurrency) by removing build from hot path entirely P1b: API requests skip session/auth/cookie flows - Channel detection (isApiRequest) moved before Session::start() - Session::start(), remember-me, session timeout, tenant refresh and locale resolution wrapped in if-not-API guard - API endpoints no longer set cookies or trigger login redirects - Default locale set separately for API so t() works in error responses P3: extract(Router::getParameters(), EXTR_SKIP) - Prevents URL parameters from overwriting existing scope variables Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/module-build.php | 2 + lib/App/Module/ModuleRuntimePageBuilder.php | 38 +++ web/index.php | 315 +++++++++++--------- 3 files changed, 210 insertions(+), 145 deletions(-) diff --git a/bin/module-build.php b/bin/module-build.php index 9cd9c74..ab0211b 100644 --- a/bin/module-build.php +++ b/bin/module-build.php @@ -51,6 +51,8 @@ function moduleBuildRun(): int $builder = new ModuleRuntimePageBuilder(); $result = $builder->build($runtimeDir, __DIR__ . '/../pages', $modules); + ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules); + if (count($modules) === 0) { fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n"); } else { diff --git a/lib/App/Module/ModuleRuntimePageBuilder.php b/lib/App/Module/ModuleRuntimePageBuilder.php index 2a23618..d601e16 100644 --- a/lib/App/Module/ModuleRuntimePageBuilder.php +++ b/lib/App/Module/ModuleRuntimePageBuilder.php @@ -103,6 +103,44 @@ final class ModuleRuntimePageBuilder } } + // ── Fingerprint: detect stale runtime state ───────────────────── + + /** + * Compute the expected fingerprint for a set of modules. + * + * @param array $modules + */ + public static function expectedFingerprint(array $modules): string + { + $ids = array_map(static fn (ModuleManifest $m): string => $m->id, $modules); + sort($ids); + + return implode(',', $ids); + } + + /** + * Read the persisted fingerprint from disk. + */ + public static function readFingerprint(string $runtimeDir): string + { + $file = dirname($runtimeDir) . '/.module-fingerprint'; + + return is_file($file) ? trim((string) file_get_contents($file)) : ''; + } + + /** + * Write a fingerprint after a successful build so web/index.php can + * detect stale runtime state without rebuilding on every request. + * + * @param array $modules + */ + public static function writeFingerprint(string $runtimeDir, array $modules): void + { + $file = dirname($runtimeDir) . '/.module-fingerprint'; + + file_put_contents($file, self::expectedFingerprint($modules)); + } + private function removeDirectoryContents(string $dir): void { $entries = scandir($dir); diff --git a/web/index.php b/web/index.php index 615224e..d6193d3 100644 --- a/web/index.php +++ b/web/index.php @@ -20,118 +20,53 @@ use MintyPHP\Service\Auth\AuthServicesFactory; use MintyPHP\Session; use MintyPHP\Support\Flash; -// Change directory to project root +// ── 1. Bootstrap ──────────────────────────────────────────────────── + chdir(__DIR__ . '/..'); -// Load the libraries require 'vendor/autoload.php'; -// Load the config parameters require 'config/config.php'; -// Register shortcut functions require 'lib/Support/helpers.php'; $container = require 'lib/App/registerContainer.php'; setAppContainer($container); -// Resolve runtime page root before routes are initialized. +// ── 2. Module runtime validation ──────────────────────────────────── +// Fail-fast if the runtime page root is stale (modules changed without +// running module-runtime-sync). No auto-build in the request path — +// that is the responsibility of bin/module-runtime-sync.php. + $moduleRegistry = app(ModuleRegistry::class); -if (count($moduleRegistry->getModules()) > 0) { - $runtimePageRoot = __DIR__ . '/../storage/runtime/pages'; - if (!is_dir($runtimePageRoot) && !is_link($runtimePageRoot)) { - $builder = new ModuleRuntimePageBuilder(); - $builder->build($runtimePageRoot, __DIR__ . '/../pages', $moduleRegistry->getModules()); +$runtimePageRoot = __DIR__ . '/../storage/runtime/pages'; +$activeModules = $moduleRegistry->getModules(); + +if (count($activeModules) > 0 || is_dir($runtimePageRoot) || is_link($runtimePageRoot)) { + $expectedFingerprint = ModuleRuntimePageBuilder::expectedFingerprint($activeModules); + $actualFingerprint = ModuleRuntimePageBuilder::readFingerprint($runtimePageRoot); + if ($expectedFingerprint !== $actualFingerprint) { + throw new RuntimeException( + "Module runtime is stale (expected: '{$expectedFingerprint}', actual: '{$actualFingerprint}'). " + . 'Run: php bin/module-runtime-sync.php' + ); } if (is_dir($runtimePageRoot) || is_link($runtimePageRoot)) { Router::$pageRoot = 'storage/runtime/pages/'; } } -// Load routes after container/module registry are available. +// ── 3. Routes ─────────────────────────────────────────────────────── + require 'config/router.php'; +// ── 4. Request context ────────────────────────────────────────────── + RequestContext::start(); header('X-Request-Id: ' . RequestContext::requestHeaderValue()); -// Start the firewall Firewall::start(); -// Start the session -Session::start(); -$authServicesFactory = app(AuthServicesFactory::class); -$authService = $authServicesFactory->createAuthService(); -$rememberMeService = $authServicesFactory->createRememberMeService(); +// ── 5. Parse URI + detect channel ─────────────────────────────────── +// Channel detection happens BEFORE session/auth so that API requests +// never touch cookies, session state or remember-me flows. -// Initialize default locale from tenant/app settings -$defaultLocale = appDefaultLocale(); -if ($defaultLocale !== null) { - I18n::$defaultLocale = $defaultLocale; - if (I18n::$locale === '') { - I18n::$locale = $defaultLocale; - } -} - -// Auto-login from remember-me cookie -if (empty($_SESSION['user']['id'])) { - $autoLoginResult = $rememberMeService->autoLoginFromCookie(); - if ($autoLoginResult) { - // The session timeout flash is no longer relevant — the user was - // seamlessly re-authenticated and never saw the login page. - Flash::dismissByKey('session_timeout', 'login'); - - // After auto-login (e.g. session timeout with active remember-me cookie), - // honour ?return_to= so the user lands on the page they were on. - $returnToParam = trim((string) ($_GET['return_to'] ?? '')); - if ($returnToParam !== '') { - $intendedUrlService = app(IntendedUrlService::class); - $sanitized = $intendedUrlService->sanitize($returnToParam); - if ($sanitized !== '') { - $routePath = $intendedUrlService->toRoutePath($sanitized); - Router::redirect($routePath); - } - } - } -} - -// Session timeout enforcement (idle + absolute) -if (!empty($_SESSION['user']['id'])) { - $now = time(); - $sessionStartedAt = (int) ($_SESSION['session_started_at'] ?? 0); - $sessionLastActivity = (int) ($_SESSION['session_last_activity'] ?? 0); - - // Gracefully handle pre-existing sessions that lack timestamps. - if ($sessionStartedAt === 0) { - $_SESSION['session_started_at'] = $now; - $sessionStartedAt = $now; - } - if ($sessionLastActivity === 0) { - $_SESSION['session_last_activity'] = $now; - $sessionLastActivity = $now; - } - - $sessionGateway = app(\MintyPHP\Service\Settings\SettingsSessionGateway::class); - $idleTimeoutSeconds = $sessionGateway->getSessionIdleTimeoutSeconds(); - $absoluteTimeoutSeconds = $sessionGateway->getSessionAbsoluteTimeoutSeconds(); - - $isIdle = ($now - $sessionLastActivity) > $idleTimeoutSeconds; - $isAbsolute = ($now - $sessionStartedAt) > $absoluteTimeoutSeconds; - - if ($isIdle || $isAbsolute) { - // Capture the current URL before session destruction so the user - // can be redirected back after re-authentication. - $intendedUrl = $_SERVER['REQUEST_URI'] ?? ''; - $authService->logoutDueToSessionTimeout(); - Flash::error(t('Your session has expired. Please log in again.'), 'login', 'session_timeout'); - $intendedUrlService = app(IntendedUrlService::class); - $locale = I18n::$locale ?? I18n::$defaultLocale; - $loginTarget = $intendedUrlService->buildLoginRedirect( - Request::withLocale('login', $locale), - $intendedUrl - ); - Router::redirect($loginTarget); - } - - $_SESSION['session_last_activity'] = $now; -} - -// Parse request URI and resolve locale $originalUri = $_SERVER['REQUEST_URI'] ?? ''; $_SERVER['MINTY_ORIGINAL_URI'] = $originalUri; @@ -142,7 +77,6 @@ $requestedLocale = $parsedUri['locale']; $_SERVER['MINTY_LOCALE'] = $requestedLocale; RequestContext::setPath($pathWithoutLocale); -// Rewrite REQUEST_URI without locale segment for router if ($parsedUri['hadLocaleInUrl']) { $_SERVER['REQUEST_URI'] = $localeResolver->buildUriWithoutLocale( $pathWithoutLocale, @@ -150,22 +84,26 @@ if ($parsedUri['hadLocaleInUrl']) { ); } -// Resolve effective locale from URL > Session > Cookie > Default -I18n::$locale = $localeResolver->resolveLocale( - $requestedLocale, - $_SESSION['user']['locale'] ?? null, - $_COOKIE['locale'] ?? null -); - $isAssetRequest = AssetDetector::isAssetRequest($pathWithoutLocale); $isApiRequest = str_starts_with($pathWithoutLocale, 'api/'); RequestContext::setChannel($isApiRequest ? 'api' : 'web'); +// ── 6. API channel ────────────────────────────────────────────────── +// API endpoints use Bearer-token auth (ApiBootstrap). They never need +// PHP sessions, remember-me cookies, locale redirects or tenant UI refresh. + if ($isApiRequest) { if (!defined('MINTY_API_REQUEST')) { define('MINTY_API_REQUEST', true); } + // Set default locale so t() works in API error messages. + $defaultLocale = appDefaultLocale(); + if ($defaultLocale !== null) { + I18n::$defaultLocale = $defaultLocale; + I18n::$locale = $defaultLocale; + } + // Minty router enforces CSRF for non-GET requests before action execution. // Our API is Bearer-token based and does not use form-CSRF, so mark only // API write requests as AJAX to bypass the router-level form CSRF gate. @@ -176,63 +114,151 @@ if ($isApiRequest) { } } -// Refresh tenant context if stale or missing (skip for assets and API requests) -if (!empty($_SESSION['user']['id']) && !$isAssetRequest && !$isApiRequest) { - $userId = (int) ($_SESSION['user']['id'] ?? 0); - if ($userId > 0) { - $authState = $authService->refreshSessionAuthState($userId); - if (!empty($authState['logout_required'])) { - $authService->logout(); - $reason = (string) ($authState['reason'] ?? ''); - if ($reason === 'inactive') { - Flash::error(t('Account is inactive'), 'login', 'login_inactive'); - } else { - Flash::error(t('No active tenant assigned'), 'login', 'no_active_tenant'); +// ── 7. Web channel: session, auth, locale, tenant ─────────────────── + +if (!$isApiRequest) { + // Start the session + Session::start(); + $authServicesFactory = app(AuthServicesFactory::class); + $authService = $authServicesFactory->createAuthService(); + $rememberMeService = $authServicesFactory->createRememberMeService(); + + // Initialize default locale from tenant/app settings + $defaultLocale = appDefaultLocale(); + if ($defaultLocale !== null) { + I18n::$defaultLocale = $defaultLocale; + if (I18n::$locale === '') { + I18n::$locale = $defaultLocale; + } + } + + // Auto-login from remember-me cookie + if (empty($_SESSION['user']['id'])) { + $autoLoginResult = $rememberMeService->autoLoginFromCookie(); + if ($autoLoginResult) { + // The session timeout flash is no longer relevant — the user was + // seamlessly re-authenticated and never saw the login page. + Flash::dismissByKey('session_timeout', 'login'); + + // After auto-login (e.g. session timeout with active remember-me cookie), + // honour ?return_to= so the user lands on the page they were on. + $returnToParam = trim((string) ($_GET['return_to'] ?? '')); + if ($returnToParam !== '') { + $intendedUrlService = app(IntendedUrlService::class); + $sanitized = $intendedUrlService->sanitize($returnToParam); + if ($sanitized !== '') { + $routePath = $intendedUrlService->toRoutePath($sanitized); + Router::redirect($routePath); + } } - Router::redirect('login'); + } + } + + // Session timeout enforcement (idle + absolute) + if (!empty($_SESSION['user']['id'])) { + $now = time(); + $sessionStartedAt = (int) ($_SESSION['session_started_at'] ?? 0); + $sessionLastActivity = (int) ($_SESSION['session_last_activity'] ?? 0); + + // Gracefully handle pre-existing sessions that lack timestamps. + if ($sessionStartedAt === 0) { + $_SESSION['session_started_at'] = $now; + $sessionStartedAt = $now; + } + if ($sessionLastActivity === 0) { + $_SESSION['session_last_activity'] = $now; + $sessionLastActivity = $now; } - $ttl = 120; - $lastRefresh = (int) ($_SESSION['tenant_snapshot_at'] ?? 0); - $needsRefresh = empty($_SESSION['current_tenant']) || empty($_SESSION['available_tenants']); - if ((time() - $lastRefresh) >= $ttl) { - $needsRefresh = true; + $sessionGateway = app(\MintyPHP\Service\Settings\SettingsSessionGateway::class); + $idleTimeoutSeconds = $sessionGateway->getSessionIdleTimeoutSeconds(); + $absoluteTimeoutSeconds = $sessionGateway->getSessionAbsoluteTimeoutSeconds(); + + $isIdle = ($now - $sessionLastActivity) > $idleTimeoutSeconds; + $isAbsolute = ($now - $sessionStartedAt) > $absoluteTimeoutSeconds; + + if ($isIdle || $isAbsolute) { + // Capture the current URL before session destruction so the user + // can be redirected back after re-authentication. + $intendedUrl = $_SERVER['REQUEST_URI'] ?? ''; + $authService->logoutDueToSessionTimeout(); + Flash::error(t('Your session has expired. Please log in again.'), 'login', 'session_timeout'); + $intendedUrlService = app(IntendedUrlService::class); + $locale = I18n::$locale ?? I18n::$defaultLocale; + $loginTarget = $intendedUrlService->buildLoginRedirect( + Request::withLocale('login', $locale), + $intendedUrl + ); + Router::redirect($loginTarget); } - if ($needsRefresh) { - $authService->loadTenantDataIntoSession($userId); - $_SESSION['tenant_snapshot_at'] = time(); + + $_SESSION['session_last_activity'] = $now; + } + + // Resolve effective locale from URL > Session > Cookie > Default + I18n::$locale = $localeResolver->resolveLocale( + $requestedLocale, + $_SESSION['user']['locale'] ?? null, + $_COOKIE['locale'] ?? null + ); + + // Refresh tenant context if stale or missing (skip for asset requests) + if (!empty($_SESSION['user']['id']) && !$isAssetRequest) { + $userId = (int) ($_SESSION['user']['id'] ?? 0); + if ($userId > 0) { + $authState = $authService->refreshSessionAuthState($userId); + if (!empty($authState['logout_required'])) { + $authService->logout(); + $reason = (string) ($authState['reason'] ?? ''); + if ($reason === 'inactive') { + Flash::error(t('Account is inactive'), 'login', 'login_inactive'); + } else { + Flash::error(t('No active tenant assigned'), 'login', 'no_active_tenant'); + } + Router::redirect('login'); + } + + $ttl = 120; + $lastRefresh = (int) ($_SESSION['tenant_snapshot_at'] ?? 0); + $needsRefresh = empty($_SESSION['current_tenant']) || empty($_SESSION['available_tenants']); + if ((time() - $lastRefresh) >= $ttl) { + $needsRefresh = true; + } + if ($needsRefresh) { + $authService->loadTenantDataIntoSession($userId); + $_SESSION['tenant_snapshot_at'] = time(); + } + } + } + + // Redirect to locale-prefixed URL if missing + if ($requestedLocale === '' && !$isAssetRequest) { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + if (in_array($method, ['GET', 'HEAD'], true)) { + $target = Request::withLocale($pathWithoutLocale, I18n::$locale ?? I18n::$defaultLocale); + $target .= $parsedUri['query'] !== '' ? '?' . $parsedUri['query'] : ''; + Router::redirect($target); } } } -if ($requestedLocale === '' && !$isAssetRequest && !$isApiRequest) { - $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); - if (in_array($method, ['GET', 'HEAD'], true)) { - $target = Request::withLocale($pathWithoutLocale, I18n::$locale ?? I18n::$defaultLocale); - $target .= $parsedUri['query'] !== '' ? '?' . $parsedUri['query'] : ''; - Router::redirect($target); - } -} +// ── 8. Access control guard ───────────────────────────────────────── -// Guard: restrict non-public pages to logged-in users -// Merge core public paths with module-contributed public paths. -$modulePublicPaths = []; -if (app(ModuleRegistry::class) instanceof ModuleRegistry) { - $modulePublicPaths = app(ModuleRegistry::class)->getPublicPaths(); -} $allPublicPaths = array_values(array_unique(array_merge( defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : [], - $modulePublicPaths + $moduleRegistry->getPublicPaths() ))); $accessControl = new AccessControl($allPublicPaths); $accessControl->requireAuthOrRedirect($pathWithoutLocale, !empty($_SESSION['user']['id'])); -// Analyze the PHP code +// ── 9. Code analysis (debug only) ────────────────────────────────── + if (Debugger::$enabled) { Analyzer::execute(); } -// Load the action into body +// ── 10. Action execution ──────────────────────────────────────────── + ob_start(); if (Router::getTemplateAction()) { require Router::getTemplateAction(); @@ -253,7 +279,7 @@ if (defined('MINTY_ALLOW_OUTPUT') && MINTY_ALLOW_OUTPUT) { // @phpstan-ignore bo ob_start(); if (Router::getAction()) { - extract(Router::getParameters()); + extract(Router::getParameters(), EXTR_SKIP); require Router::getAction(); } if (ob_get_contents()) { @@ -270,8 +296,8 @@ if (defined('MINTY_ALLOW_OUTPUT') && MINTY_ALLOW_OUTPUT) { // @phpstan-ignore bo exit; } -// Resolve layout capabilities while DB/session are still open. -// Templates render after DB::close(), so authz lookup must be prepared here. +// ── 11. Layout preparation (web only, before session/DB close) ────── + if (Router::getTemplateView()) { $viewAuth = is_array($viewAuth ?? null) ? $viewAuth : []; $layoutAuth = $viewAuth['layout'] ?? null; @@ -287,27 +313,26 @@ if (Router::getTemplateView()) { ); } -// End the session -Session::end(); +// ── 12. Session & DB close ────────────────────────────────────────── -// Close the database connection +Session::end(); DB::close(); +// ── 13. View rendering ────────────────────────────────────────────── + if (Router::getTemplateView()) { Buffer::start('html'); if (Router::getView()) { require Router::getView(); } - // Show developer toolbar if (Debugger::$enabled) { Debugger::toolbar(); } Buffer::end('html'); - // Load body into template require Router::getTemplateView(); -} else { // Handle the 'none' template case +} else { if (Router::getView()) { require Router::getView(); }