diff --git a/pages/admin/departments/create().php b/pages/admin/departments/create().php index ee2678a..337ee8e 100644 --- a/pages/admin/departments/create().php +++ b/pages/admin/departments/create().php @@ -4,6 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\DepartmentAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -46,6 +47,12 @@ if ($allowedTenantIds) { $tenants = []; } +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'admin/departments/create', 'csrf_expired'); + Router::redirect('admin/departments/create'); + return; +} + if ($request->hasBody('description')) { $selectedTenantId = $request->bodyInt('tenant_id'); $selectedTenantIds = $directoryScopeGateway->filterTenantIdsForUser([$selectedTenantId], $currentUserId); diff --git a/pages/admin/departments/edit($id).php b/pages/admin/departments/edit($id).php index c9626fd..2b12c57 100644 --- a/pages/admin/departments/edit($id).php +++ b/pages/admin/departments/edit($id).php @@ -4,6 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\DepartmentAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -93,6 +94,12 @@ if ($allowedTenantIds) { $form['tenant_id'] = 0; } +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), "admin/departments/edit/{$uuid}", 'csrf_expired'); + Router::redirect("admin/departments/edit/{$uuid}"); + return; +} + if ($request->hasBody('description')) { $submitDecision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT, [ 'actor_user_id' => $currentUserId, diff --git a/pages/admin/permissions/create().php b/pages/admin/permissions/create().php index 1eba5f3..9aa5b54 100644 --- a/pages/admin/permissions/create().php +++ b/pages/admin/permissions/create().php @@ -4,6 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\PermissionAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -29,6 +30,12 @@ $form = [ 'is_system' => 0, ]; +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'admin/permissions/create', 'csrf_expired'); + Router::redirect('admin/permissions/create'); + return; +} + if ($request->hasBody('key')) { $result = app(\MintyPHP\Service\Access\PermissionGateway::class)->createFromAdmin($request->bodyAll()); $form = $result['form'] ?? $form; diff --git a/pages/admin/permissions/edit($id).php b/pages/admin/permissions/edit($id).php index 8a5f0c2..1717e01 100644 --- a/pages/admin/permissions/edit($id).php +++ b/pages/admin/permissions/edit($id).php @@ -5,6 +5,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Repository\Access\RoleRepository; use MintyPHP\Router; use MintyPHP\Service\Access\PermissionAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -44,6 +45,12 @@ $userWriteRepository = app(\MintyPHP\Repository\User\UserWriteRepository::class) $selectedRoleIds = $rolePermissionRepository->listRoleIdsByPermissionId($id); $previousRoleIds = $selectedRoleIds; +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), "admin/permissions/edit/{$id}", 'csrf_expired'); + Router::redirect("admin/permissions/edit/{$id}"); + return; +} + if ($request->hasBody('key')) { $submitDecision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT, [ 'actor_user_id' => $currentUserId, diff --git a/pages/admin/roles/create().php b/pages/admin/roles/create().php index 6309d4c..c2b6db3 100644 --- a/pages/admin/roles/create().php +++ b/pages/admin/roles/create().php @@ -4,6 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\RoleAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -32,6 +33,12 @@ $rolePermissionRepository = app(\MintyPHP\Repository\Access\RolePermissionReposi $permissions = $permissionRepository->listActive(); $selectedPermissionIds = []; +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'admin/roles/create', 'csrf_expired'); + Router::redirect('admin/roles/create'); + return; +} + if ($request->hasBody('description')) { $dbSession = app(\MintyPHP\Repository\Support\DatabaseSessionRepository::class); $transactionStarted = false; diff --git a/pages/admin/roles/edit($id).php b/pages/admin/roles/edit($id).php index b6f3ea7..633cbb0 100644 --- a/pages/admin/roles/edit($id).php +++ b/pages/admin/roles/edit($id).php @@ -4,6 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\RoleAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -63,6 +64,12 @@ $form = $role; $permissions = $permissionRepository->listActive(); $selectedPermissionIds = $rolePermissionRepository->listPermissionIdsByRoleId($roleId); +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), "admin/roles/edit/{$uuid}", 'csrf_expired'); + Router::redirect("admin/roles/edit/{$uuid}"); + return; +} + if ($request->hasBody('description')) { $submitDecision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT, [ 'actor_user_id' => $currentUserId, diff --git a/pages/admin/settings/index().php b/pages/admin/settings/index().php index baa854b..5f911e6 100644 --- a/pages/admin/settings/index().php +++ b/pages/admin/settings/index().php @@ -5,6 +5,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\SettingsAuthorizationPolicy; use MintyPHP\Service\Settings\AdminSettingsService; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -33,6 +34,12 @@ $settings = is_array($pageData['settings'] ?? null) ? $pageData['settings'] : [] $activeLoginTokens = (int) ($pageData['active_login_tokens'] ?? 0); $activeApiTokens = (int) ($pageData['active_api_tokens'] ?? 0); +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'admin/settings', 'csrf_expired'); + Router::redirect('admin/settings'); + return; +} + if ($request->isMethod('POST')) { $post = $request->bodyAll(); if (!array_key_exists('settings_submit', $post)) { diff --git a/pages/admin/tenants/create().php b/pages/admin/tenants/create().php index cbd230e..aaff90c 100644 --- a/pages/admin/tenants/create().php +++ b/pages/admin/tenants/create().php @@ -4,6 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\TenantAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -52,6 +53,12 @@ $form = [ ]; $ssoUiState = $canManageSso ? $tenantSsoService->buildMicrosoftUiState(0, $form) : []; +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'admin/tenants/create', 'csrf_expired'); + Router::redirect('admin/tenants/create'); + return; +} + if ($request->hasBody('description')) { $post = $request->bodyAll(); $ssoFields = [ diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php index 4841b6e..ee9b7ac 100644 --- a/pages/admin/tenants/edit($id).php +++ b/pages/admin/tenants/edit($id).php @@ -5,6 +5,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\TenantAuthorizationPolicy; use MintyPHP\Service\CustomField\TenantCustomFieldService; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -95,6 +96,12 @@ $errorBag = formErrors(); $errors = []; $form = array_merge($tenant, $ssoConfig); +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), "admin/tenants/edit/{$uuid}", 'csrf_expired'); + Router::redirect("admin/tenants/edit/{$uuid}"); + return; +} + if ($request->hasBody('description')) { $post = $request->bodyAll(); $submitDecision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT, [ diff --git a/pages/admin/users/access-pdf-bulk().php b/pages/admin/users/access-pdf-bulk().php index 3d82915..b85d381 100644 --- a/pages/admin/users/access-pdf-bulk().php +++ b/pages/admin/users/access-pdf-bulk().php @@ -4,6 +4,8 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\User\UserAccessPdfService; +use MintyPHP\Session; +use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; $session = app(SessionStoreInterface::class)->all(); @@ -27,6 +29,12 @@ if (strtoupper((string) (requestInput()->method())) !== 'POST') { return; } +if (!Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'admin/users', 'csrf_expired'); + Router::redirect('admin/users'); + return; +} + $errorBag = formErrors(); $raw = requestInput()->bodyAll()['uuids'] ?? ''; $uuids = []; diff --git a/pages/admin/users/api-token-create($id).php b/pages/admin/users/api-token-create($id).php index 8924227..ff02156 100644 --- a/pages/admin/users/api-token-create($id).php +++ b/pages/admin/users/api-token-create($id).php @@ -3,6 +3,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\UserAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -31,6 +32,12 @@ if (!$decision->isAllowed()) { return; } +if (!Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), "admin/users/edit/{$uuid}", 'csrf_expired'); + Router::redirect("admin/users/edit/{$uuid}"); + return; +} + $name = trim((string) (requestInput()->bodyAll()['token_name'] ?? '')); if ($name === '') { diff --git a/pages/admin/users/api-token-revoke($id).php b/pages/admin/users/api-token-revoke($id).php index f049353..46c1164 100644 --- a/pages/admin/users/api-token-revoke($id).php +++ b/pages/admin/users/api-token-revoke($id).php @@ -3,6 +3,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\UserAuthorizationPolicy; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -29,6 +30,12 @@ if (!$decision->isAllowed()) { return; } +if (!Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), "admin/users/edit/{$uuid}", 'csrf_expired'); + Router::redirect("admin/users/edit/{$uuid}"); + return; +} + $tokenId = (int) (requestInput()->bodyAll()['token_id'] ?? 0); if ($tokenId > 0) { diff --git a/pages/admin/users/bulk($action).php b/pages/admin/users/bulk($action).php index 3275923..0d39468 100644 --- a/pages/admin/users/bulk($action).php +++ b/pages/admin/users/bulk($action).php @@ -4,11 +4,17 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\User\UserAccessTemplateService; +use MintyPHP\Session; use MintyPHP\Support\Guard; $session = app(SessionStoreInterface::class)->all(); Guard::requireLogin(); +if (!Session::checkCsrfToken()) { + Router::json(['ok' => false, 'error' => 'csrf_expired']); + return; +} + $action = strtolower(trim((string) ($action ?? ''))); $allowedActions = ['activate', 'deactivate', 'delete', 'send-access']; if (!in_array($action, $allowedActions, true)) { diff --git a/pages/admin/users/create().php b/pages/admin/users/create().php index e8e51b5..cb00fec 100644 --- a/pages/admin/users/create().php +++ b/pages/admin/users/create().php @@ -9,6 +9,7 @@ use MintyPHP\Service\Access\UiAccessService; use MintyPHP\Service\Access\UiCapabilityMap; use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\CustomField\UserCustomFieldValueService; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -91,6 +92,12 @@ $form = [ 'active' => 1, ]; +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'admin/users/create', 'csrf_expired'); + Router::redirect('admin/users/create'); + return; +} + if ($request->hasBody('email')) { $post = $request->bodyAll(); $input = $post; diff --git a/pages/admin/users/edit($id).php b/pages/admin/users/edit($id).php index 8a864fc..fc29578 100644 --- a/pages/admin/users/edit($id).php +++ b/pages/admin/users/edit($id).php @@ -8,6 +8,7 @@ use MintyPHP\Repository\Auth\RememberTokenRepository; use MintyPHP\Router; use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\CustomField\UserCustomFieldValueService; +use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; @@ -173,6 +174,12 @@ if ($canViewSecurityArtifacts) { $canManageApiTokens = $canManageApiTokens && $canViewSecurityArtifacts; $showApiTokens = $canViewSecurityArtifacts && (!empty($apiTokens) || $canManageApiTokens); +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), "admin/users/edit/{$uuid}", 'csrf_expired'); + Router::redirect("admin/users/edit/{$uuid}"); + return; +} + if ($request->hasBody('email')) { $post = $request->bodyAll(); $submitDecision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT, [ diff --git a/pages/lang().php b/pages/lang().php index 2da6cdb..9fad56f 100644 --- a/pages/lang().php +++ b/pages/lang().php @@ -5,12 +5,19 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\I18n; use MintyPHP\Router; use MintyPHP\Service\User\UserServicesFactory; +use MintyPHP\Session; use MintyPHP\Support\Flash; $sessionStore = app(SessionStoreInterface::class); $session = $sessionStore->all(); $request = requestInput(); +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), '', 'csrf_expired'); + Router::redirect(''); + return; +} + $rawLocale = (string) $request->body( 'locale', $request->query('locale', $request->query('lang', '')) diff --git a/tests/Architecture/DatabaseUpdatesContractTest.php b/tests/Architecture/DatabaseUpdatesContractTest.php new file mode 100644 index 0000000..93dee36 --- /dev/null +++ b/tests/Architecture/DatabaseUpdatesContractTest.php @@ -0,0 +1,110 @@ +projectRootPath() . '/db/updates'; + if (!is_dir($dir)) { + return []; + } + + $files = []; + foreach (new DirectoryIterator($dir) as $entry) { + if ($entry->isDot() || !$entry->isFile()) { + continue; + } + $files[] = $entry->getFilename(); + } + + sort($files); + + return $files; + } + + public function testUpdateScriptsUseSqlExtension(): void + { + $nonSql = []; + foreach ($this->updateScripts() as $file) { + if (!str_ends_with($file, '.sql')) { + $nonSql[] = $file; + } + } + + $this->assertSame([], $nonSql, "Non-SQL files found in db/updates/:\n" . implode("\n", $nonSql)); + } + + public function testUpdateScriptsFollowNamingConvention(): void + { + $invalid = []; + foreach ($this->updateScripts() as $file) { + if (!preg_match('/^\d{4}-\d{2}-\d{2}-.+\.sql$/', $file)) { + $invalid[] = $file; + } + } + + $this->assertSame( + [], + $invalid, + "db/updates/ scripts must follow YYYY-MM-DD-description.sql naming:\n" . implode("\n", $invalid) + ); + } + + public function testUpdateScriptsAreNotEmpty(): void + { + $empty = []; + foreach ($this->updateScripts() as $file) { + $content = trim($this->readProjectFile('db/updates/' . $file)); + if ($content === '') { + $empty[] = $file; + } + } + + $this->assertSame([], $empty, "Empty scripts found in db/updates/:\n" . implode("\n", $empty)); + } + + public function testUpdateScriptsUseIdempotentPatterns(): void + { + $violations = []; + + foreach ($this->updateScripts() as $file) { + $content = $this->readProjectFile('db/updates/' . $file); + $upper = strtoupper($content); + + // Every script must contain at least one idempotent pattern. + $hasIfNotExists = str_contains($upper, 'IF NOT EXISTS'); + $hasOnDuplicateKey = str_contains($upper, 'ON DUPLICATE KEY'); + $hasInsertIgnore = str_contains($upper, 'INSERT IGNORE'); + $hasIfExists = str_contains($upper, 'IF EXISTS'); + $hasDropIfExists = (bool) preg_match('/DROP\s+(TABLE|INDEX|COLUMN)\s+IF\s+EXISTS/i', $content); + // UPDATE ... SET is inherently idempotent (convergent writes). + $hasIdempotentUpdate = (bool) preg_match('/UPDATE\s+\S+\s+SET\s+/i', $content); + + if (!$hasIfNotExists && !$hasOnDuplicateKey && !$hasInsertIgnore && !$hasIfExists && !$hasDropIfExists && !$hasIdempotentUpdate) { + $violations[] = $file . ' (no idempotent guard found)'; + } + + // Unguarded CREATE TABLE is forbidden. + if (preg_match('/CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i', $content)) { + $violations[] = $file . ' (CREATE TABLE without IF NOT EXISTS)'; + } + } + + $this->assertSame( + [], + $violations, + "db/updates/ scripts must use idempotent SQL patterns (GR-CORE-010):\n" . implode("\n", $violations) + ); + } +} diff --git a/tests/Architecture/PostEndpointCsrfContractTest.php b/tests/Architecture/PostEndpointCsrfContractTest.php new file mode 100644 index 0000000..9e35c6a --- /dev/null +++ b/tests/Architecture/PostEndpointCsrfContractTest.php @@ -0,0 +1,80 @@ +projectRootPath() . '/pages'; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($pagesDir)); + + $missing = []; + + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relativePath = 'pages/' . ltrim(str_replace($pagesDir, '', $file->getPathname()), '/'); + + if ($this->isCsrfExempt($relativePath)) { + continue; + } + + $content = (string) file_get_contents($file->getPathname()); + + if (!$this->handlesPostData($content)) { + continue; + } + + if (!str_contains($content, 'checkCsrfToken')) { + $missing[] = $relativePath; + } + } + + sort($missing); + + $this->assertSame( + [], + $missing, + "POST handlers missing CSRF verification (GR-SEC-001):\n" . implode("\n", $missing) + ); + } + + private function isCsrfExempt(string $path): bool + { + foreach (self::CSRF_EXEMPT_PATTERNS as $pattern) { + if (preg_match($pattern, $path)) { + return true; + } + } + + return false; + } + + private function handlesPostData(string $content): bool + { + return str_contains($content, "isMethod('POST')") + || str_contains($content, 'hasBody(') + || str_contains($content, 'bodyAll()') + || preg_match('/->body\(/', $content) === 1; + } + +}