harden: web/index.php — runtime fingerprint, API channel isolation, extract() safety

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:41:59 +01:00
parent c328067aa6
commit 476f688bd8
3 changed files with 210 additions and 145 deletions

View File

@@ -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 {

View File

@@ -103,6 +103,44 @@ final class ModuleRuntimePageBuilder
}
}
// ── Fingerprint: detect stale runtime state ─────────────────────
/**
* Compute the expected fingerprint for a set of modules.
*
* @param array<string, ModuleManifest> $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<string, ModuleManifest> $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);

View File

@@ -20,56 +20,120 @@ 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) {
$originalUri = $_SERVER['REQUEST_URI'] ?? '';
$_SERVER['MINTY_ORIGINAL_URI'] = $originalUri;
$localeResolver = new LocaleResolver();
$parsedUri = $localeResolver->parseUri($originalUri);
$pathWithoutLocale = $parsedUri['pathWithoutLocale'];
$requestedLocale = $parsedUri['locale'];
$_SERVER['MINTY_LOCALE'] = $requestedLocale;
RequestContext::setPath($pathWithoutLocale);
if ($parsedUri['hadLocaleInUrl']) {
$_SERVER['REQUEST_URI'] = $localeResolver->buildUriWithoutLocale(
$pathWithoutLocale,
$parsedUri['query']
);
}
$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.
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$isApiWriteMethod = !in_array($requestMethod, ['GET', 'HEAD', 'OPTIONS'], true);
if ($isApiWriteMethod && empty($_SERVER['HTTP_X_REQUESTED_WITH'])) {
$_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
}
}
// ── 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'])) {
// 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
@@ -88,10 +152,10 @@ if (empty($_SESSION['user']['id'])) {
}
}
}
}
}
// Session timeout enforcement (idle + absolute)
if (!empty($_SESSION['user']['id'])) {
// 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);
@@ -129,55 +193,17 @@ if (!empty($_SESSION['user']['id'])) {
}
$_SESSION['session_last_activity'] = $now;
}
}
// Parse request URI and resolve locale
$originalUri = $_SERVER['REQUEST_URI'] ?? '';
$_SERVER['MINTY_ORIGINAL_URI'] = $originalUri;
$localeResolver = new LocaleResolver();
$parsedUri = $localeResolver->parseUri($originalUri);
$pathWithoutLocale = $parsedUri['pathWithoutLocale'];
$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,
$parsedUri['query']
);
}
// Resolve effective locale from URL > Session > Cookie > Default
I18n::$locale = $localeResolver->resolveLocale(
// 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');
if ($isApiRequest) {
if (!defined('MINTY_API_REQUEST')) {
define('MINTY_API_REQUEST', true);
}
// 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.
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$isApiWriteMethod = !in_array($requestMethod, ['GET', 'HEAD', 'OPTIONS'], true);
if ($isApiWriteMethod && empty($_SERVER['HTTP_X_REQUESTED_WITH'])) {
$_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
}
}
// Refresh tenant context if stale or missing (skip for assets and API requests)
if (!empty($_SESSION['user']['id']) && !$isAssetRequest && !$isApiRequest) {
// 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);
@@ -203,36 +229,36 @@ if (!empty($_SESSION['user']['id']) && !$isAssetRequest && !$isApiRequest) {
$_SESSION['tenant_snapshot_at'] = time();
}
}
}
}
if ($requestedLocale === '' && !$isAssetRequest && !$isApiRequest) {
// 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);
}
}
}
// 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();
}
// ── 8. Access control guard ─────────────────────────────────────────
$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();
}