From 6e197096fd49ee334eaebfa853cd6ba38fea2790 Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Mon, 2 Feb 2026 10:34:19 +0000 Subject: [PATCH] Bug 38365: Add CSP violation reporting API endpoint Add /api/v1/public/csp-reports endpoint to receive and log Content-Security-Policy violation reports from browsers. When CSP is configured with a report-uri directive pointing to this endpoint, browsers will POST violation reports whenever a CSP rule is violated. These reports are logged using Koha's logging system for administrator review. Features: - Public endpoint (no authentication required, as browsers send these reports automatically) - Logs violations at WARN level with key details (violated directive, blocked URI, document URI, source location) - Logs full report at DEBUG level for detailed analysis - Accepts both application/csp-report and application/json content types Configuration example (in koha-conf.xml csp_header_value): ...; report-uri /api/v1/public/csp-reports Log4perl configuration to capture CSP violations: log4perl.logger.api.csp = WARN, CSP log4perl.appender.CSP = Log::Log4perl::Appender::File log4perl.appender.CSP.filename = /var/log/koha/csp-violations.log log4perl.appender.CSP.layout = PatternLayout log4perl.appender.CSP.layout.ConversionPattern = [%d] %m%n Also updates koha-conf.xml documentation with CSP configuration guidance. Signed-off-by: David Cook Signed-off-by: Lari Taskula --- Koha/REST/V1/CSPReports.pm | 94 ++++++++++++++++++++ api/v1/swagger/definitions/csp_report.yaml | 47 ++++++++++ api/v1/swagger/paths/public_csp_reports.yaml | 43 +++++++++ api/v1/swagger/swagger.yaml | 4 + debian/templates/koha-conf-site.xml.in | 30 +++++++ etc/koha-conf.xml | 30 +++++++ t/db_dependent/api/v1/public/csp_reports.t | 80 +++++++++++++++++ 7 files changed, 328 insertions(+) create mode 100644 Koha/REST/V1/CSPReports.pm create mode 100644 api/v1/swagger/definitions/csp_report.yaml create mode 100644 api/v1/swagger/paths/public_csp_reports.yaml create mode 100755 t/db_dependent/api/v1/public/csp_reports.t diff --git a/Koha/REST/V1/CSPReports.pm b/Koha/REST/V1/CSPReports.pm new file mode 100644 index 00000000000..874ea9144e8 --- /dev/null +++ b/Koha/REST/V1/CSPReports.pm @@ -0,0 +1,94 @@ +package Koha::REST::V1::CSPReports; + +# Copyright 2025 Koha Development Team +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; + +use Koha::Logger; + +=head1 NAME + +Koha::REST::V1::CSPReports - Controller for Content-Security-Policy violation reports + +=head1 DESCRIPTION + +This controller provides an endpoint to receive CSP violation reports from browsers. +Reports are logged using Koha's logging system for analysis and debugging. + +=head1 METHODS + +=head2 add + +Receives a CSP violation report and logs it. + +Browsers send CSP violations as JSON POST requests when a Content-Security-Policy +is violated. This endpoint logs those reports for administrator review. + +=cut + +sub add { + my $c = shift->openapi->valid_input or return; + + my $report = $c->req->json; + + # CSP reports come wrapped in a 'csp-report' key + my $csp_report = $report->{'csp-report'} // $report; + + my $logger = Koha::Logger->get( { interface => 'api', category => 'csp' } ); + + # Extract key fields for logging + my $document_uri = $csp_report->{'document-uri'} // 'unknown'; + my $violated_dir = $csp_report->{'violated-directive'} // 'unknown'; + my $blocked_uri = $csp_report->{'blocked-uri'} // 'unknown'; + my $source_file = $csp_report->{'source-file'} // ''; + my $line_number = $csp_report->{'line-number'} // ''; + my $column_number = $csp_report->{'column-number'} // ''; + + # Build location string if available + my $location = ''; + if ($source_file) { + $location = " at $source_file"; + $location .= ":$line_number" if $line_number; + $location .= ":$column_number" if $column_number; + } + + $logger->warn( + sprintf( + "CSP violation: '%s' blocked '%s' on page '%s'%s", + $violated_dir, + $blocked_uri, + $document_uri, + $location + ) + ); + + # Log full report at debug level for detailed analysis + if ( $logger->is_debug ) { + require JSON; + $logger->debug( "CSP report details: " . JSON::encode_json($csp_report) ); + } + + return $c->render( + status => 204, + openapi => undef + ); +} + +1; diff --git a/api/v1/swagger/definitions/csp_report.yaml b/api/v1/swagger/definitions/csp_report.yaml new file mode 100644 index 00000000000..e175c911f2f --- /dev/null +++ b/api/v1/swagger/definitions/csp_report.yaml @@ -0,0 +1,47 @@ +--- +type: object +description: | + A Content-Security-Policy violation report as sent by browsers. + See https://www.w3.org/TR/CSP3/#violation-reports for specification. +properties: + csp-report: + type: object + description: The CSP violation report object + properties: + document-uri: + type: string + description: The URI of the document where the violation occurred + referrer: + type: string + description: The referrer of the document where the violation occurred + violated-directive: + type: string + description: The directive that was violated (e.g., "script-src 'self'") + effective-directive: + type: string + description: The effective directive that was violated + original-policy: + type: string + description: The original CSP policy as specified in the header + disposition: + type: string + description: Either "enforce" or "report" depending on CSP mode + blocked-uri: + type: string + description: The URI of the resource that was blocked + line-number: + type: integer + description: Line number in the document where the violation occurred + column-number: + type: integer + description: Column number where the violation occurred + source-file: + type: string + description: The URI of the script where the violation occurred + status-code: + type: integer + description: HTTP status code of the document + script-sample: + type: string + description: First 40 characters of the inline script that caused the violation +additionalProperties: true diff --git a/api/v1/swagger/paths/public_csp_reports.yaml b/api/v1/swagger/paths/public_csp_reports.yaml new file mode 100644 index 00000000000..735b5c81ca4 --- /dev/null +++ b/api/v1/swagger/paths/public_csp_reports.yaml @@ -0,0 +1,43 @@ +"/public/csp-reports": + post: + x-mojo-to: CSPReports#add + operationId: addCSPReport + tags: + - csp + summary: Report a Content-Security-Policy violation + description: | + This endpoint receives Content-Security-Policy violation reports from browsers. + When a CSP violation occurs, browsers can be configured to POST a report to this + endpoint using the `report-uri` or `report-to` CSP directive. + + Reports are logged using Koha's logging system for administrator review. + + This is a public endpoint that does not require authentication, as browsers + send these reports automatically without user credentials. + consumes: + - application/csp-report + - application/json + produces: + - application/json + parameters: + - name: body + in: body + description: CSP violation report + required: true + schema: + $ref: "../swagger.yaml#/definitions/csp_report" + responses: + "204": + description: Report received successfully + "400": + description: Bad request + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: Internal server error + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index 9a9b7af7565..afd3e0331ea 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -42,6 +42,8 @@ definitions: $ref: ./definitions/circ-rule-kind.yaml city: $ref: ./definitions/city.yaml + csp_report: + $ref: ./definitions/csp_report.yaml credit: $ref: ./definitions/credit.yaml debit: @@ -565,6 +567,8 @@ paths: $ref: "./paths/libraries.yaml#/~1public~1libraries~1{library_id}" "/public/lists": $ref: "./paths/lists.yaml#/~1public~1lists" + "/public/csp-reports": + $ref: ./paths/public_csp_reports.yaml#/~1public~1csp-reports "/public/oauth/login/{provider_code}/{interface}": $ref: ./paths/public_oauth.yaml#/~1public~1oauth~1login~1{provider_code}~1{interface} "/public/patrons/{patron_id}/article_requests/{article_request_id}": diff --git a/debian/templates/koha-conf-site.xml.in b/debian/templates/koha-conf-site.xml.in index 03fbba33170..e90b7784b73 100644 --- a/debian/templates/koha-conf-site.xml.in +++ b/debian/templates/koha-conf-site.xml.in @@ -500,6 +500,36 @@ __END_SRU_PUBLICSERVER__ 1 + diff --git a/etc/koha-conf.xml b/etc/koha-conf.xml index 10336b9b09d..991d9318b86 100644 --- a/etc/koha-conf.xml +++ b/etc/koha-conf.xml @@ -311,6 +311,36 @@ 1 + diff --git a/t/db_dependent/api/v1/public/csp_reports.t b/t/db_dependent/api/v1/public/csp_reports.t new file mode 100755 index 00000000000..3be9bd845af --- /dev/null +++ b/t/db_dependent/api/v1/public/csp_reports.t @@ -0,0 +1,80 @@ +#!/usr/bin/env perl + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Test::NoWarnings; +use Test::More tests => 2; +use Test::Mojo; +use Test::Warn; + +use t::lib::Mocks; + +use Koha::Database; + +my $schema = Koha::Database->new->schema; +my $t = Test::Mojo->new('Koha::REST::V1'); + +t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); + +subtest 'add() tests' => sub { + + plan tests => 6; + + $schema->storage->txn_begin; + + # Test with standard CSP report format + my $csp_report = { + 'csp-report' => { + 'document-uri' => 'https://library.example.org/cgi-bin/koha/opac-main.pl', + 'referrer' => '', + 'violated-directive' => "script-src 'self' 'nonce-abc123'", + 'effective-directive' => 'script-src', + 'original-policy' => "default-src 'self'; script-src 'self' 'nonce-abc123'", + 'disposition' => 'enforce', + 'blocked-uri' => 'inline', + 'line-number' => 42, + 'column-number' => 10, + 'source-file' => 'https://library.example.org/cgi-bin/koha/opac-main.pl', + 'status-code' => 200, + } + }; + + # Anonymous request should work (browsers send these without auth) + $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report ) + ->status_is( 204, 'CSP report accepted' ); + + # Test with application/json content type (also valid) + $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/json' } => json => $csp_report ) + ->status_is( 204, 'CSP report accepted with application/json content type' ); + + # Test with minimal report + my $minimal_report = { + 'csp-report' => { + 'document-uri' => 'https://library.example.org/', + 'violated-directive' => 'script-src', + 'blocked-uri' => 'https://evil.example.com/script.js', + } + }; + + $t->post_ok( '/api/v1/public/csp-reports' => json => $minimal_report ) + ->status_is( 204, 'Minimal CSP report accepted' ); + + $schema->storage->txn_rollback; +}; + +1; -- 2.39.5