2026-03-10 15:40:07 +01:00
|
|
|
<?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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 17:59:57 +02:00
|
|
|
if (!str_contains($content, 'checkCsrfToken') && !str_contains($content, 'actionRequireCsrf(')) {
|
2026-03-10 15:40:07 +01:00
|
|
|
$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')")
|
2026-04-24 17:59:57 +02:00
|
|
|
|| str_contains($content, "requestInput()->method()")
|
|
|
|
|
|| str_contains($content, "(requestInput()->method())")
|
2026-03-10 15:40:07 +01:00
|
|
|
|| str_contains($content, 'hasBody(')
|
|
|
|
|
|| str_contains($content, 'bodyAll()')
|
|
|
|
|
|| preg_match('/->body\(/', $content) === 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|