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; // GET-only allowlist: aggregator callers without any POST // indicator are render-only and don't need CSRF. 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|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 $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 <<