forked from fa/breadcrumb-the-shire
fix(security): add CSRF protection to all POST endpoints + contract tests (B2)
CSRF guards added to 16 admin pages that previously accepted POST requests without token verification (GR-SEC-001): departments, permissions, roles, settings, tenants, users (create/edit/bulk/tokens), and lang switch. New contract tests: - PostEndpointCsrfContractTest: scans all pages/ for POST handlers and verifies checkCsrfToken is present (API/data endpoints exempt). - DatabaseUpdatesContractTest: validates db/updates/ scripts follow naming convention and use idempotent SQL patterns (GR-CORE-010). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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', ''))
|
||||
|
||||
110
tests/Architecture/DatabaseUpdatesContractTest.php
Normal file
110
tests/Architecture/DatabaseUpdatesContractTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use DirectoryIterator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* GR-CORE-010: Schema/data changes for existing installations MUST be shipped
|
||||
* as idempotent SQL scripts in db/updates/.
|
||||
*/
|
||||
class DatabaseUpdatesContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
private function updateScripts(): array
|
||||
{
|
||||
$dir = $this->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)
|
||||
);
|
||||
}
|
||||
}
|
||||
80
tests/Architecture/PostEndpointCsrfContractTest.php
Normal file
80
tests/Architecture/PostEndpointCsrfContractTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
|
||||
/**
|
||||
* GR-SEC-001: Every POST endpoint in pages/ MUST verify CSRF token before
|
||||
* processing the request body.
|
||||
*/
|
||||
class PostEndpointCsrfContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/** Files that use API-token auth instead of CSRF (data endpoints, API v1). */
|
||||
private const CSRF_EXEMPT_PATTERNS = [
|
||||
'#/api/v1/#',
|
||||
'#/data\(\)\.php$#',
|
||||
];
|
||||
|
||||
public function testAllPostHandlersVerifyCsrfToken(): void
|
||||
{
|
||||
$pagesDir = $this->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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user