View | Details | Raw Unified | Return to bug 38365
Collapse All | Expand All

(-)a/Koha/REST/V1/CSPReports.pm (+94 lines)
Line 0 Link Here
1
package Koha::REST::V1::CSPReports;
2
3
# Copyright 2025 Koha Development Team
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Koha is distributed in the hope that it will be useful, but
13
# WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with Koha; if not, see <https://www.gnu.org/licenses>.
19
20
use Modern::Perl;
21
22
use Mojo::Base 'Mojolicious::Controller';
23
24
use Koha::Logger;
25
26
=head1 NAME
27
28
Koha::REST::V1::CSPReports - Controller for Content-Security-Policy violation reports
29
30
=head1 DESCRIPTION
31
32
This controller provides an endpoint to receive CSP violation reports from browsers.
33
Reports are logged using Koha's logging system for analysis and debugging.
34
35
=head1 METHODS
36
37
=head2 add
38
39
Receives a CSP violation report and logs it.
40
41
Browsers send CSP violations as JSON POST requests when a Content-Security-Policy
42
is violated. This endpoint logs those reports for administrator review.
43
44
=cut
45
46
sub add {
47
    my $c = shift->openapi->valid_input or return;
48
49
    my $report = $c->req->json;
50
51
    # CSP reports come wrapped in a 'csp-report' key
52
    my $csp_report = $report->{'csp-report'} // $report;
53
54
    my $logger = Koha::Logger->get( { interface => 'api', category => 'csp' } );
55
56
    # Extract key fields for logging
57
    my $document_uri  = $csp_report->{'document-uri'}       // 'unknown';
58
    my $violated_dir  = $csp_report->{'violated-directive'} // 'unknown';
59
    my $blocked_uri   = $csp_report->{'blocked-uri'}        // 'unknown';
60
    my $source_file   = $csp_report->{'source-file'}        // '';
61
    my $line_number   = $csp_report->{'line-number'}        // '';
62
    my $column_number = $csp_report->{'column-number'}      // '';
63
64
    # Build location string if available
65
    my $location = '';
66
    if ($source_file) {
67
        $location = " at $source_file";
68
        $location .= ":$line_number"   if $line_number;
69
        $location .= ":$column_number" if $column_number;
70
    }
71
72
    $logger->warn(
73
        sprintf(
74
            "CSP violation: '%s' blocked '%s' on page '%s'%s",
75
            $violated_dir,
76
            $blocked_uri,
77
            $document_uri,
78
            $location
79
        )
80
    );
81
82
    # Log full report at debug level for detailed analysis
83
    if ( $logger->is_debug ) {
84
        require JSON;
85
        $logger->debug( "CSP report details: " . JSON::encode_json($csp_report) );
86
    }
87
88
    return $c->render(
89
        status  => 204,
90
        openapi => undef
91
    );
92
}
93
94
1;
(-)a/api/v1/swagger/definitions/csp_report.yaml (+47 lines)
Line 0 Link Here
1
---
2
type: object
3
description: |
4
  A Content-Security-Policy violation report as sent by browsers.
5
  See https://www.w3.org/TR/CSP3/#violation-reports for specification.
6
properties:
7
  csp-report:
8
    type: object
9
    description: The CSP violation report object
10
    properties:
11
      document-uri:
12
        type: string
13
        description: The URI of the document where the violation occurred
14
      referrer:
15
        type: string
16
        description: The referrer of the document where the violation occurred
17
      violated-directive:
18
        type: string
19
        description: The directive that was violated (e.g., "script-src 'self'")
20
      effective-directive:
21
        type: string
22
        description: The effective directive that was violated
23
      original-policy:
24
        type: string
25
        description: The original CSP policy as specified in the header
26
      disposition:
27
        type: string
28
        description: Either "enforce" or "report" depending on CSP mode
29
      blocked-uri:
30
        type: string
31
        description: The URI of the resource that was blocked
32
      line-number:
33
        type: integer
34
        description: Line number in the document where the violation occurred
35
      column-number:
36
        type: integer
37
        description: Column number where the violation occurred
38
      source-file:
39
        type: string
40
        description: The URI of the script where the violation occurred
41
      status-code:
42
        type: integer
43
        description: HTTP status code of the document
44
      script-sample:
45
        type: string
46
        description: First 40 characters of the inline script that caused the violation
47
additionalProperties: true
(-)a/api/v1/swagger/paths/public_csp_reports.yaml (+43 lines)
Line 0 Link Here
1
"/public/csp-reports":
2
  post:
3
    x-mojo-to: CSPReports#add
4
    operationId: addCSPReport
5
    tags:
6
      - csp
7
    summary: Report a Content-Security-Policy violation
8
    description: |
9
      This endpoint receives Content-Security-Policy violation reports from browsers.
10
      When a CSP violation occurs, browsers can be configured to POST a report to this
11
      endpoint using the `report-uri` or `report-to` CSP directive.
12
13
      Reports are logged using Koha's logging system for administrator review.
14
15
      This is a public endpoint that does not require authentication, as browsers
16
      send these reports automatically without user credentials.
17
    consumes:
18
      - application/csp-report
19
      - application/json
20
    produces:
21
      - application/json
22
    parameters:
23
      - name: body
24
        in: body
25
        description: CSP violation report
26
        required: true
27
        schema:
28
          $ref: "../swagger.yaml#/definitions/csp_report"
29
    responses:
30
      "204":
31
        description: Report received successfully
32
      "400":
33
        description: Bad request
34
        schema:
35
          $ref: "../swagger.yaml#/definitions/error"
36
      "500":
37
        description: Internal server error
38
        schema:
39
          $ref: "../swagger.yaml#/definitions/error"
40
      "503":
41
        description: Under maintenance
42
        schema:
43
          $ref: "../swagger.yaml#/definitions/error"
(-)a/api/v1/swagger/swagger.yaml (+4 lines)
Lines 42-47 definitions: Link Here
42
    $ref: ./definitions/circ-rule-kind.yaml
42
    $ref: ./definitions/circ-rule-kind.yaml
43
  city:
43
  city:
44
    $ref: ./definitions/city.yaml
44
    $ref: ./definitions/city.yaml
45
  csp_report:
46
    $ref: ./definitions/csp_report.yaml
45
  credit:
47
  credit:
46
    $ref: ./definitions/credit.yaml
48
    $ref: ./definitions/credit.yaml
47
  debit:
49
  debit:
Lines 565-570 paths: Link Here
565
    $ref: "./paths/libraries.yaml#/~1public~1libraries~1{library_id}"
567
    $ref: "./paths/libraries.yaml#/~1public~1libraries~1{library_id}"
566
  "/public/lists":
568
  "/public/lists":
567
    $ref: "./paths/lists.yaml#/~1public~1lists"
569
    $ref: "./paths/lists.yaml#/~1public~1lists"
570
  "/public/csp-reports":
571
    $ref: ./paths/public_csp_reports.yaml#/~1public~1csp-reports
568
  "/public/oauth/login/{provider_code}/{interface}":
572
  "/public/oauth/login/{provider_code}/{interface}":
569
    $ref: ./paths/public_oauth.yaml#/~1public~1oauth~1login~1{provider_code}~1{interface}
573
    $ref: ./paths/public_oauth.yaml#/~1public~1oauth~1login~1{provider_code}~1{interface}
570
  "/public/patrons/{patron_id}/article_requests/{article_request_id}":
574
  "/public/patrons/{patron_id}/article_requests/{article_request_id}":
(-)a/debian/templates/koha-conf-site.xml.in (+30 lines)
Lines 500-505 __END_SRU_PUBLICSERVER__ Link Here
500
   <parallel_loops_count>1</parallel_loops_count>
500
   <parallel_loops_count>1</parallel_loops_count>
501
 </auto_renew_cronjob>
501
 </auto_renew_cronjob>
502
502
503
 <!--
504
   Content-Security-Policy (CSP) configuration
505
   ============================================
506
   CSP is a security feature that helps prevent XSS attacks by controlling which
507
   resources the browser is allowed to load.
508
509
   csp_mode options:
510
     - (empty/unset): CSP is disabled (default)
511
     - report-only: CSP violations are reported but not enforced (for testing)
512
     - enabled: CSP is fully enforced
513
514
   csp_header_value: The CSP policy directives. Special placeholders:
515
     - _CSP_NONCE_: Replaced with a unique nonce for each request
516
517
   Recommended workflow:
518
     1. Start with report-only mode to identify violations
519
     2. Review logs and fix any legitimate violations
520
     3. Switch to enabled mode once violations are resolved
521
522
   To enable violation reporting, add report-uri to your policy pointing to Koha's
523
   built-in CSP report endpoint. Example with reporting:
524
     <csp_header_value>default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; report-uri /api/v1/public/csp-reports</csp_header_value>
525
526
   Reports are logged via Koha's logging system. Configure log4perl to capture them:
527
     log4perl.logger.api.csp = WARN, CSP
528
     log4perl.appender.CSP = Log::Log4perl::Appender::File
529
     log4perl.appender.CSP.filename = /var/log/koha/__KOHASITE__/csp-violations.log
530
     log4perl.appender.CSP.layout = PatternLayout
531
     log4perl.appender.CSP.layout.ConversionPattern = [%d] %m%n
532
 -->
503
 <content_security_policy>
533
 <content_security_policy>
504
   <opac>
534
   <opac>
505
     <!-- supported values: report-only, enabled. Any other value, including unset, disables the feature. -->
535
     <!-- supported values: report-only, enabled. Any other value, including unset, disables the feature. -->
(-)a/etc/koha-conf.xml (+30 lines)
Lines 311-316 Link Here
311
   <parallel_loops_count>1</parallel_loops_count>
311
   <parallel_loops_count>1</parallel_loops_count>
312
 </auto_renew_cronjob>
312
 </auto_renew_cronjob>
313
313
314
 <!--
315
   Content-Security-Policy (CSP) configuration
316
   ============================================
317
   CSP is a security feature that helps prevent XSS attacks by controlling which
318
   resources the browser is allowed to load.
319
320
   csp_mode options:
321
     - (empty/unset): CSP is disabled (default)
322
     - report-only: CSP violations are reported but not enforced (for testing)
323
     - enabled: CSP is fully enforced
324
325
   csp_header_value: The CSP policy directives. Special placeholders:
326
     - _CSP_NONCE_: Replaced with a unique nonce for each request
327
328
   Recommended workflow:
329
     1. Start with report-only mode to identify violations
330
     2. Review logs and fix any legitimate violations
331
     3. Switch to enabled mode once violations are resolved
332
333
   To enable violation reporting, add report-uri to your policy pointing to Koha's
334
   built-in CSP report endpoint. Example with reporting:
335
     <csp_header_value>default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; report-uri /api/v1/public/csp-reports</csp_header_value>
336
337
   Reports are logged via Koha's logging system. Configure log4perl to capture them:
338
     log4perl.logger.api.csp = WARN, CSP
339
     log4perl.appender.CSP = Log::Log4perl::Appender::File
340
     log4perl.appender.CSP.filename = /var/log/koha/csp-violations.log
341
     log4perl.appender.CSP.layout = PatternLayout
342
     log4perl.appender.CSP.layout.ConversionPattern = [%d] %m%n
343
 -->
314
 <content_security_policy>
344
 <content_security_policy>
315
   <opac>
345
   <opac>
316
     <!-- supported values: report-only, enabled. Any other value, including unset, disables the feature. -->
346
     <!-- supported values: report-only, enabled. Any other value, including unset, disables the feature. -->
(-)a/t/db_dependent/api/v1/public/csp_reports.t (-1 / +80 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/env perl
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it
6
# under the terms of the GNU General Public License as published by
7
# the Free Software Foundation; either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# Koha is distributed in the hope that it will be useful, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with Koha; if not, see <https://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
20
use Test::NoWarnings;
21
use Test::More tests => 2;
22
use Test::Mojo;
23
use Test::Warn;
24
25
use t::lib::Mocks;
26
27
use Koha::Database;
28
29
my $schema = Koha::Database->new->schema;
30
my $t      = Test::Mojo->new('Koha::REST::V1');
31
32
t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
33
34
subtest 'add() tests' => sub {
35
36
    plan tests => 6;
37
38
    $schema->storage->txn_begin;
39
40
    # Test with standard CSP report format
41
    my $csp_report = {
42
        'csp-report' => {
43
            'document-uri'        => 'https://library.example.org/cgi-bin/koha/opac-main.pl',
44
            'referrer'            => '',
45
            'violated-directive'  => "script-src 'self' 'nonce-abc123'",
46
            'effective-directive' => 'script-src',
47
            'original-policy'     => "default-src 'self'; script-src 'self' 'nonce-abc123'",
48
            'disposition'         => 'enforce',
49
            'blocked-uri'         => 'inline',
50
            'line-number'         => 42,
51
            'column-number'       => 10,
52
            'source-file'         => 'https://library.example.org/cgi-bin/koha/opac-main.pl',
53
            'status-code'         => 200,
54
        }
55
    };
56
57
    # Anonymous request should work (browsers send these without auth)
58
    $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
59
        ->status_is( 204, 'CSP report accepted' );
60
61
    # Test with application/json content type (also valid)
62
    $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/json' } => json => $csp_report )
63
        ->status_is( 204, 'CSP report accepted with application/json content type' );
64
65
    # Test with minimal report
66
    my $minimal_report = {
67
        'csp-report' => {
68
            'document-uri'       => 'https://library.example.org/',
69
            'violated-directive' => 'script-src',
70
            'blocked-uri'        => 'https://evil.example.com/script.js',
71
        }
72
    };
73
74
    $t->post_ok( '/api/v1/public/csp-reports' => json => $minimal_report )
75
        ->status_is( 204, 'Minimal CSP report accepted' );
76
77
    $schema->storage->txn_rollback;
78
};
79
80
1;

Return to bug 38365