Files
breadcrumb-the-shire/tests/Architecture/PostEndpointCsrfContractTest.php

83 lines
2.3 KiB
PHP
Raw Permalink Normal View History

<?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') && !str_contains($content, 'actionRequireCsrf(')) {
$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, "requestInput()->method()")
|| str_contains($content, "(requestInput()->method())")
|| str_contains($content, 'hasBody(')
|| str_contains($content, 'bodyAll()')
|| preg_match('/->body\(/', $content) === 1;
}
}