2026-04-25 23:52:27 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Architecture;
|
|
|
|
|
|
|
|
|
|
use FilesystemIterator;
|
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
use RecursiveDirectoryIterator;
|
|
|
|
|
use RecursiveIteratorIterator;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GR-SEC-001 extension: every page action that calls one of the
|
2026-04-26 17:47:36 +02:00
|
|
|
* action-context aggregators (actionEditContext, actionCreateContext)
|
|
|
|
|
* AND handles POST data MUST also call actionRequireCsrf() before it
|
|
|
|
|
* accesses the request body.
|
2026-04-25 23:52:27 +02:00
|
|
|
*
|
|
|
|
|
* Closes plan-stage finding SEC-PLAN-004 from Run
|
|
|
|
|
* 2026-04-25-action-context-helper. Replaces the obsolete
|
|
|
|
|
* ActionContextHelperContractTest::testNoProductionCallSitesYet which became
|
|
|
|
|
* stale once Step 2 introduced the first production caller (Departments-Edit
|
|
|
|
|
* pilot, Run 2026-04-25-action-context-rollout-step2-departments).
|
|
|
|
|
*
|
|
|
|
|
* Order requirement: actionRequireCsrf() must appear BEFORE the first
|
|
|
|
|
* POST-body indicator (bodyAll(), bodyInt(), body( etc.). It does NOT need to
|
|
|
|
|
* appear before the aggregator call itself — the aggregator only resolves
|
|
|
|
|
* model + capabilities for the GET-render and is safe to run before CSRF
|
|
|
|
|
* verification, mirroring the canonical Departments-Edit ordering preserved by
|
|
|
|
|
* Run 2026-04-25-action-context-rollout-step2-departments
|
|
|
|
|
* (Verhaltens-Identitäts-Pflicht).
|
|
|
|
|
*
|
|
|
|
|
* Detection is conservative: when an aggregator call cannot be statically
|
|
|
|
|
* located (e.g. wrapped in a heredoc or dynamic dispatch), the test fails
|
|
|
|
|
* explicitly with "Cannot verify CSRF pairing — review manually" rather than
|
|
|
|
|
* silently passing. Pattern reference: DetailDrawerFragmentContractTest.
|
|
|
|
|
*/
|
|
|
|
|
class ActionContextCsrfPairingContractTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
use ProjectFileAssertionSupport;
|
|
|
|
|
|
|
|
|
|
private const AGGREGATORS = [
|
|
|
|
|
'actionEditContext',
|
|
|
|
|
'actionCreateContext',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST-handling presence indicators (does the file handle POST at all?).
|
|
|
|
|
* Mirrors PostEndpointCsrfContractTest::handlesPostData.
|
|
|
|
|
*/
|
|
|
|
|
private const POST_PRESENCE_INDICATORS = [
|
|
|
|
|
"isMethod('POST')",
|
|
|
|
|
'requestInput()->method()',
|
|
|
|
|
'hasBody(',
|
|
|
|
|
'bodyAll()',
|
|
|
|
|
'bodyInt(',
|
|
|
|
|
'bodyString(',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Body-access indicators. These must NOT happen before
|
|
|
|
|
* actionRequireCsrf(). isMethod('POST')/method() are guards, not body
|
|
|
|
|
* accesses, so they're excluded here — guards may legitimately come
|
|
|
|
|
* before the CSRF call (in the very same line as actionRequireCsrf).
|
|
|
|
|
*/
|
|
|
|
|
private const BODY_ACCESS_INDICATORS = [
|
|
|
|
|
'hasBody(',
|
|
|
|
|
'bodyAll()',
|
|
|
|
|
'bodyInt(',
|
|
|
|
|
'bodyString(',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
public function testActionsUsingAggregatorsHaveCsrfBeforeAggregator(): void
|
|
|
|
|
{
|
|
|
|
|
$root = $this->projectRootPath();
|
|
|
|
|
|
|
|
|
|
$missing = [];
|
|
|
|
|
$unverifiable = [];
|
|
|
|
|
$orderViolations = [];
|
|
|
|
|
|
|
|
|
|
foreach (['pages', 'modules'] as $dir) {
|
|
|
|
|
$base = $root . '/' . $dir;
|
|
|
|
|
if (!is_dir($base)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
|
|
|
|
|
/** @var \SplFileInfo $file */
|
|
|
|
|
foreach ($iterator as $file) {
|
|
|
|
|
if (!$file->isFile() || $file->getExtension() !== 'php') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$content = (string) file_get_contents($file->getPathname());
|
|
|
|
|
$aggregatorOffsets = $this->locateAggregatorCalls($content);
|
|
|
|
|
if ($aggregatorOffsets === []) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$relativePath = str_replace($root . '/', '', $file->getPathname());
|
|
|
|
|
|
|
|
|
|
// Non-static-extractable aggregator pattern (heredoc, eval, …).
|
|
|
|
|
if ($aggregatorOffsets === false) {
|
|
|
|
|
$unverifiable[] = $relativePath;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$isPostHandler = $this->hasAny($content, self::POST_PRESENCE_INDICATORS)
|
|
|
|
|
|| preg_match('/->body\(/', $content) === 1;
|
|
|
|
|
|
2026-04-26 17:47:36 +02:00
|
|
|
// GET-only allowlist: aggregator callers without any POST
|
|
|
|
|
// indicator are render-only and don't need CSRF.
|
2026-04-25 23:52:27 +02:00
|
|
|
if (!$isPostHandler) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$csrfOffset = $this->locateActionRequireCsrf($content);
|
|
|
|
|
if ($csrfOffset === null) {
|
|
|
|
|
$missing[] = $relativePath;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$bodyAccessOffset = $this->locateFirstBodyAccess($content);
|
|
|
|
|
if ($bodyAccessOffset !== null && $csrfOffset >= $bodyAccessOffset) {
|
|
|
|
|
$orderViolations[] = sprintf(
|
|
|
|
|
'%s — actionRequireCsrf() at offset %d but POST body access at offset %d (CSRF must precede body access)',
|
|
|
|
|
$relativePath,
|
|
|
|
|
$csrfOffset,
|
|
|
|
|
$bodyAccessOffset
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sort($missing);
|
|
|
|
|
sort($unverifiable);
|
|
|
|
|
sort($orderViolations);
|
|
|
|
|
|
|
|
|
|
$messages = [];
|
|
|
|
|
if ($missing !== []) {
|
|
|
|
|
$messages[] = "Aggregator-callers handling POST without actionRequireCsrf():\n " . implode("\n ", $missing);
|
|
|
|
|
}
|
|
|
|
|
if ($orderViolations !== []) {
|
|
|
|
|
$messages[] = "Aggregator-callers with actionRequireCsrf() AFTER body access:\n " . implode("\n ", $orderViolations);
|
|
|
|
|
}
|
|
|
|
|
if ($unverifiable !== []) {
|
|
|
|
|
$messages[] = "Cannot verify CSRF pairing — review manually:\n " . implode("\n ", $unverifiable);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->assertSame(
|
|
|
|
|
[],
|
|
|
|
|
$messages,
|
|
|
|
|
"GR-SEC-001 / aggregator pairing violations:\n" . implode("\n", $messages)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return list<int>|false list of byte offsets of aggregator calls;
|
|
|
|
|
* false when at least one match is wrapped in a
|
|
|
|
|
* heredoc/string literal and cannot be statically
|
|
|
|
|
* verified.
|
|
|
|
|
*/
|
|
|
|
|
private function locateAggregatorCalls(string $content): array|false
|
|
|
|
|
{
|
|
|
|
|
$offsets = [];
|
|
|
|
|
foreach (self::AGGREGATORS as $name) {
|
|
|
|
|
$needle = $name . '(';
|
|
|
|
|
$pos = 0;
|
|
|
|
|
while (($found = strpos($content, $needle, $pos)) !== false) {
|
|
|
|
|
if ($this->isInsideStringLiteral($content, $found)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$offsets[] = $found;
|
|
|
|
|
$pos = $found + strlen($needle);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sort($offsets);
|
|
|
|
|
return $offsets;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function locateFirstBodyAccess(string $content): ?int
|
|
|
|
|
{
|
|
|
|
|
$earliest = null;
|
|
|
|
|
foreach (self::BODY_ACCESS_INDICATORS as $needle) {
|
|
|
|
|
$pos = strpos($content, $needle);
|
|
|
|
|
if ($pos !== false && ($earliest === null || $pos < $earliest)) {
|
|
|
|
|
$earliest = $pos;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (preg_match('/->body\(/', $content, $m, PREG_OFFSET_CAPTURE) === 1) {
|
|
|
|
|
$pos = (int) $m[0][1];
|
|
|
|
|
if ($earliest === null || $pos < $earliest) {
|
|
|
|
|
$earliest = $pos;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return $earliest;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function locateActionRequireCsrf(string $content): ?int
|
|
|
|
|
{
|
|
|
|
|
$pos = strpos($content, 'actionRequireCsrf(');
|
|
|
|
|
return $pos === false ? null : $pos;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param list<string> $needles
|
|
|
|
|
*/
|
|
|
|
|
private function hasAny(string $content, array $needles): bool
|
|
|
|
|
{
|
|
|
|
|
foreach ($needles as $needle) {
|
|
|
|
|
if (str_contains($content, $needle)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Heuristic: a match is considered inside a heredoc/string literal when
|
|
|
|
|
* the immediately preceding non-whitespace character is a backtick or an
|
|
|
|
|
* unescaped quote on the same line. Conservative — favours raising
|
|
|
|
|
* "review manually" over silent pass.
|
|
|
|
|
*/
|
|
|
|
|
private function isInsideStringLiteral(string $content, int $offset): bool
|
|
|
|
|
{
|
|
|
|
|
if ($offset <= 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Walk back to start of line.
|
|
|
|
|
$lineStart = strrpos(substr($content, 0, $offset), "\n");
|
|
|
|
|
$lineStart = $lineStart === false ? 0 : $lineStart + 1;
|
|
|
|
|
$prefix = substr($content, $lineStart, $offset - $lineStart);
|
|
|
|
|
|
|
|
|
|
// Count unescaped single/double quotes on the line so far.
|
|
|
|
|
$singles = 0;
|
|
|
|
|
$doubles = 0;
|
|
|
|
|
$len = strlen($prefix);
|
|
|
|
|
for ($i = 0; $i < $len; $i++) {
|
|
|
|
|
$ch = $prefix[$i];
|
|
|
|
|
if ($ch === '\\') {
|
|
|
|
|
$i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($ch === "'") {
|
|
|
|
|
$singles++;
|
|
|
|
|
} elseif ($ch === '"') {
|
|
|
|
|
$doubles++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Odd counts → currently inside that quote type.
|
|
|
|
|
if (($singles % 2) === 1 || ($doubles % 2) === 1) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
// Heredoc detection — simplistic: if the line above ended with <<<TAG
|
|
|
|
|
// and we haven't seen the closing tag yet, we'd be inside. Skip — the
|
|
|
|
|
// test fails-loud on unverifiable cases.
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|