Self-Hosted CSP Violation Reporting Endpoint
This sets up your own Self-Hosted CSP Reporting(PHP) script to receive Content Security Policy (CSP) violation reports directly from visitors’ browsers — no third-party service required. When a browser blocks a resource due to your CSP, it will automatically send a report to this endpoint, which logs the details for you to review.
1. Upload the Endpoint Script
Upload csp-report-endpoint.php to your server. You have two placement options:
- Simplest: your web root, e.g.
/public_html/csp-report-endpoint.php, reachable athttps://yourdomain.com/csp-report-endpoint.php - More private: keep the PHP file in your web root, but point its log file constant to a location outside the public web root so the raw log can never be requested directly by a visitor.
2. The Endpoint Script
<?php
/**
* Self-hosted CSP violation report receiver.
*
* Accepts POST reports sent by browsers via the CSP `report-to` /
* legacy `report-uri` mechanisms and logs them to a local file.
*
* Place this file somewhere web-accessible, e.g.:
* https://yourdomain.com/csp-report-endpoint.php
*
* Then reference that URL in your Reporting-Endpoints / report-uri
* headers (see instructions below).
*/
// ---- Configuration ----------------------------------------------------
// Where to store reports. Keep this OUTSIDE the public web root if
// possible (e.g. one level above your site's public_html) so the raw
// log file itself can't be requested directly by anyone.
define('CSP_LOG_FILE', __DIR__ . '/csp-violations.log');
// Optional: only accept reports for these page hosts, to reduce noise
// from stray/forged requests. Leave empty array to accept all.
define('CSP_ALLOWED_HOSTS', ['yourdomain.com', 'www.yourdomain.com']);
// Optional: max log file size in bytes before rotating (5 MB default).
define('CSP_MAX_LOG_BYTES', 5 * 1024 * 1024);
// ---- End configuration -------------------------------------------------
// Only accept POST requests.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}
// Read the raw POST body. Browsers send CSP reports as
// application/csp-report (legacy report-uri) or application/reports+json
// (modern report-to / Reporting API), both JSON-encoded bodies.
$rawBody = file_get_contents('php://input');
if ($rawBody === false || trim($rawBody) === '') {
http_response_code(400);
exit;
}
$decoded = json_decode($rawBody, true);
if ($decoded === null) {
// Not valid JSON — ignore silently, respond 204 so the browser
// doesn't retry endlessly.
http_response_code(204);
exit;
}
// Normalize: legacy report-uri sends {"csp-report": {...}}.
// Modern report-to (Reporting API) sends an ARRAY of report objects,
// each shaped like {"type":"csp-violation","url":"...","body":{...}}.
$normalizedReports = [];
if (isset($decoded['csp-report'])) {
// Legacy format — single report.
$normalizedReports[] = [
'source' => 'report-uri (legacy)',
'data' => $decoded['csp-report'],
];
} elseif (is_array($decoded) && array_is_list($decoded)) {
// Modern Reporting API format — array of reports, possibly mixed types.
foreach ($decoded as $entry) {
if (($entry['type'] ?? '') === 'csp-violation') {
$normalizedReports[] = [
'source' => 'report-to (modern)',
'data' => $entry['body'] ?? $entry,
'page_url' => $entry['url'] ?? null,
];
}
}
} else {
// Unrecognized shape — log raw for inspection but don't crash.
$normalizedReports[] = [
'source' => 'unknown-format',
'data' => $decoded,
];
}
if (empty($normalizedReports)) {
http_response_code(204);
exit;
}
// Optional host filtering.
$allowedHosts = CSP_ALLOWED_HOSTS;
foreach ($normalizedReports as $report) {
$data = $report['data'];
$documentUri = $data['documentURI'] ?? $data['document-uri'] ?? $report['page_url'] ?? '';
$blockedUri = $data['blockedURI'] ?? $data['blocked-uri'] ?? '';
$violatedDir = $data['violatedDirective'] ?? $data['violated-directive'] ?? $data['effectiveDirective'] ?? '';
$sourceFile = $data['sourceFile'] ?? $data['source-file'] ?? '';
$lineNumber = $data['lineNumber'] ?? $data['line-number'] ?? '';
$disposition = $data['disposition'] ?? '';
if (!empty($allowedHosts) && $documentUri) {
$host = parse_url($documentUri, PHP_URL_HOST);
if ($host && !in_array($host, $allowedHosts, true)) {
continue; // skip reports for hosts we don't care about
}
}
$logEntry = [
'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
'source' => $report['source'],
'disposition' => $disposition, // "enforce" or "report"
'document_uri' => $documentUri,
'violated_directive'=> $violatedDir,
'blocked_uri' => $blockedUri,
'source_file' => $sourceFile,
'line_number' => $lineNumber,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'remote_addr' => $_SERVER['REMOTE_ADDR'] ?? '',
];
writeCspLogEntry($logEntry);
}
http_response_code(204); // No Content — expected success response.
exit;
/**
* Append a single log entry as a JSON line, with basic rotation.
*/
function writeCspLogEntry(array $entry): void
{
$logFile = CSP_LOG_FILE;
// Basic rotation: if file exceeds max size, archive it with a timestamp.
if (file_exists($logFile) && filesize($logFile) > CSP_MAX_LOG_BYTES) {
rename($logFile, $logFile . '.' . date('Ymd-His') . '.bak');
}
$line = json_encode($entry, JSON_UNESCAPED_SLASHES) . PHP_EOL;
// Use a lock to avoid corrupted lines under concurrent requests.
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
}
3. Wire It Into Your CSP Headers
In the same Apache config where your Content-Security-Policy header lives, add:
Header always set Reporting-Endpoints "csp-endpoint=\"https://gossamerwebdesign.com/csp-report-endpoint.php\""
Then append both the modern and legacy reporting directives to your existing CSP string, for broad browser support:
Header always set Content-Security-Policy "...(your existing full policy, all directives)...; report-uri https://gossamerwebdesign.com/csp-report-endpoint.php; report-to csp-endpoint;"
Reload Apache and hard-refresh your site to pick up the change:
apachectl configtest; apachectl graceful or sudo systemctl reload apache2
4. Test It
Trigger a deliberate violation — for example, temporarily load an image from a domain not listed in img-src — then check the log:
tail -f /path/to/csp-violations.log
You should see a JSON line appear within a second or two of the page loading.
5. View a Readable Summary
Raw JSON log lines are hard to scan by eye. This companion script groups violations by directive and blocked URI, with counts, so repeated issues collapse into a single row instead of flooding your terminal:
<?php
/**
* Simple summary viewer for csp-violations.log.
*
* Run from the command line:
* php csp-report-summary.php
*/
define('CSP_LOG_FILE', __DIR__ . '/csp-violations.log');
if (!file_exists(CSP_LOG_FILE)) {
echo "No violations logged yet.\n";
exit;
}
$lines = file(CSP_LOG_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$grouped = [];
foreach ($lines as $line) {
$entry = json_decode($line, true);
if (!$entry) {
continue;
}
// Group by directive + blocked URI so repeated identical violations
// collapse into one row with a count, instead of flooding the output.
$key = ($entry['violated_directive'] ?? '?') . ' | ' . ($entry['blocked_uri'] ?? '?');
if (!isset($grouped[$key])) {
$grouped[$key] = [
'directive' => $entry['violated_directive'] ?? '?',
'blocked_uri' => $entry['blocked_uri'] ?? '?',
'count' => 0,
'first_seen' => $entry['timestamp'] ?? '?',
'last_seen' => $entry['timestamp'] ?? '?',
'example_page'=> $entry['document_uri'] ?? '?',
];
}
$grouped[$key]['count']++;
$grouped[$key]['last_seen'] = $entry['timestamp'] ?? $grouped[$key]['last_seen'];
}
// Sort by count, descending, so the most frequent problems show up first.
uasort($grouped, fn($a, $b) => $b['count'] <=> $a['count']);
printf("%-3s %-20s %-45s %-25s %-9s %s\n", 'CT', 'Directive', 'Blocked URI', 'Example Page', 'Last Seen', '');
echo str_repeat('-', 130) . "\n";
foreach ($grouped as $row) {
printf(
"%-3d %-20s %-45s %-25s %s\n",
$row['count'],
substr($row['directive'], 0, 20),
substr($row['blocked_uri'], 0, 45),
substr(parse_url($row['example_page'], PHP_URL_PATH) ?? $row['example_page'], 0, 25),
$row['last_seen']
);
}
echo "\nTotal distinct violation types: " . count($grouped) . "\n";
Run it anytime with:
php csp-report-summary.php
Self-Hosted CSP Reporting Security Notes
- The endpoint intentionally requires no authentication, since browsers POST to it anonymously — that’s normal for this pattern. The JSON validation and host filtering built into the script guard against the endpoint being useful for anything beyond mild log noise if someone probes it directly.
- Don’t expose
csp-report-summary.phpin a public directory without protection (HTTP Basic Auth via.htaccess, or run it via SSH/CLI as shown above) — it reveals internal details about your site’s CSP posture. - Consider a scheduled cron job that emails a summary on a regular basis, so violations don’t go unnoticed between manual checks.