['pipe', 'r'], 1 => ['file', $null, 'a'], 2 => ['file', $null, 'a'], ]; $env = $_ENV; $env['CSP_TEST_MODE'] = (string) $mode; $env['CSP_TEST_ALTERNATES'] = (string) $alternates; $env['CSP_TEST_REPORT_URI'] = (string) $reportUri; $env['CSP_TEST_ENFORCE'] = $enforce ? '1' : '0'; $proc = proc_open($cmd, $descriptors, $pipes, $docroot, $env); if (!is_resource($proc)) { throw new RuntimeException('proc_open failed for php -S'); } $deadline = microtime(true) + 3.0; $ready = false; // The first probes fail with "connection refused" until the server binds. // That is expected, so swallow those warnings (the test runner would // otherwise flag the test as risky) rather than relying on the '@' operator, // which PHPUnit's error handler ignores. set_error_handler(static function () { return true; }); try { while (microtime(true) < $deadline) { $probe = stream_socket_client('tcp://127.0.0.1:' . (int) $port, $errno, $errstr, 0.2); if ($probe !== false) { fclose($probe); $ready = true; break; } usleep(50000); } } finally { restore_error_handler(); } if (!$ready) { proc_terminate($proc, 9); proc_close($proc); throw new RuntimeException('php -S failed to start on port ' . $port); } return ['proc' => $proc, 'port' => $port, 'pipes' => $pipes]; } function _csp_stop_server($server): void { if (!is_array($server) || empty($server['proc']) || !is_resource($server['proc'])) { return; } proc_terminate($server['proc'], 15); foreach ($server['pipes'] as $p) { if (is_resource($p)) { fclose($p); } } proc_close($server['proc']); } /* * GET the fixture in a single request. The built-in server spawns a fresh * process per request, so a HEAD followed by a GET would produce two nonces; * one request keeps the header nonce and the body nonce in sync. */ function _csp_fetch($port): array { $ch = curl_init('http://127.0.0.1:' . (int) $port . '/'); curl_setopt($ch, CURLOPT_HEADER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); $raw = curl_exec($ch); if ($raw === false) { $err = curl_error($ch); curl_close($ch); throw new RuntimeException('curl failed: ' . $err); } $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $header_size = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); $body = substr($raw, $header_size); $headers = []; foreach (preg_split('/\r?\n/', trim(substr($raw, 0, $header_size))) as $line) { if ($line === '' || strpos($line, 'HTTP/') === 0) { continue; } $pos = strpos($line, ':'); if ($pos === false) { continue; } $name = strtolower(trim(substr($line, 0, $pos))); $headers[$name][] = trim(substr($line, $pos + 1)); } return ['status' => $status, 'headers' => $headers, 'body' => (string) $body]; } // ---- default (HTMX) ---- test('HTMX default keeps unsafe-inline and adds no nonce or eval', function () { $server = _csp_start_server(''); try { $resp = _csp_fetch($server['port']); expect($resp['status'])->toBe(200); $csp = $resp['headers']['content-security-policy'][0]; expect($csp)->toContain("script-src 'self' 'unsafe-inline'"); expect($csp)->not->toContain('nonce-'); expect($csp)->not->toContain("'unsafe-eval'"); expect($csp)->not->toContain('report-uri'); expect($resp['headers'])->not->toHaveKey('content-security-policy-report-only'); // bootstrap script carries no nonce attribute outside nonce mode expect($resp['body'])->toContain("