Bug 38365: Add application/reports+json as accepted Content-Type

report-to directive uses application/reports+json

To test:
1. prove t/db_dependent/api/v1/public/csp_reports.t

Signed-off-by: David Cook <dcook@prosentient.com.au>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
This commit is contained in:
Lari Taskula 2026-02-08 11:27:34 +02:00 committed by Jonathan Druart
parent a472e6354a
commit d3d0592eb7
No known key found for this signature in database
GPG Key ID: A085E712BEF0E0F0
6 changed files with 241 additions and 184 deletions

View File

@ -49,7 +49,7 @@ sub add {
my $report = $c->req->json;
# CSP reports come wrapped in a 'csp-report' key
my $csp_report = $report->{'csp-report'} // $report;
my $csp_report = $report->{'csp-report'} // $report->{'body'} // $report;
my $logger = Koha::Logger->get( { interface => 'csp' } );

View File

@ -4,61 +4,57 @@ 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
maxLength: 200
referrer:
type: string
description: The referrer of the document where the violation occurred
maxLength: 200
violated-directive:
type: string
description: The directive that was violated (e.g., "script-src 'self'")
maxLength: 100
effective-directive:
type: string
description: The effective directive that was violated
maxLength: 100
original-policy:
type: string
description: The original CSP policy as specified in the header
maxLength: 400
disposition:
type: string
description: Either "enforce" or "report" depending on CSP mode
enum:
- enforce
- report
blocked-uri:
type: string
description: The URI of the resource that was blocked
maxLength: 200
line-number:
type: integer
description: Line number in the document where the violation occurred
minimum: 0
maximum: 999999
column-number:
type: integer
description: Column number where the violation occurred
minimum: 0
maximum: 999999
source-file:
type: string
description: The URI of the script where the violation occurred
maxLength: 200
status-code:
type: integer
description: HTTP status code of the document
minimum: 0
maximum: 999
script-sample:
type: string
description: First 40 characters of the inline script that caused the violation
maxLength: 41
additionalProperties: true
document-uri:
type: string
description: The URI of the document where the violation occurred
maxLength: 200
referrer:
type: string
description: The referrer of the document where the violation occurred
maxLength: 200
violated-directive:
type: string
description: The directive that was violated (e.g., "script-src 'self'")
maxLength: 100
effective-directive:
type: string
description: The effective directive that was violated
maxLength: 100
original-policy:
type: string
description: The original CSP policy as specified in the header
maxLength: 400
disposition:
type: string
description: Either "enforce" or "report" depending on CSP mode
enum:
- enforce
- report
blocked-uri:
type: string
description: The URI of the resource that was blocked
maxLength: 200
line-number:
type: integer
description: Line number in the document where the violation occurred
minimum: 0
maximum: 999999
column-number:
type: integer
description: Column number where the violation occurred
minimum: 0
maximum: 999999
source-file:
type: string
description: The URI of the script where the violation occurred
maxLength: 200
status-code:
type: integer
description: HTTP status code of the document
minimum: 0
maximum: 999
script-sample:
type: string
description: First 40 characters of the inline script that caused the violation
maxLength: 41
additionalProperties: true

View File

@ -0,0 +1,27 @@
---
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:
$ref: "csp_report.yaml"
body:
$ref: "csp_report.yaml"
user_agent:
type: string
maxLength: 300
destination:
type: string
maxLength: 200
url:
type: string
maxLength: 200
timestamp:
type: string
maxLength: 50
attempts:
type: integer
minimum: 0
maximum: 99
additionalProperties: true

View File

@ -17,6 +17,7 @@
consumes:
- application/csp-report
- application/json
- application/reports+json
produces:
- application/json
parameters:
@ -25,7 +26,7 @@
description: CSP violation report
required: true
schema:
$ref: "../swagger.yaml#/definitions/csp_report"
$ref: "../swagger.yaml#/definitions/csp_report_uri_to"
responses:
"204":
description: Report received successfully

View File

@ -44,6 +44,8 @@ definitions:
$ref: ./definitions/city.yaml
csp_report:
$ref: ./definitions/csp_report.yaml
csp_report_uri_to:
$ref: ./definitions/csp_report_uri_to.yaml
credit:
$ref: ./definitions/credit.yaml
debit:

View File

@ -36,146 +36,171 @@ t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
subtest 'add() tests' => sub {
plan tests => 15;
plan tests => 2;
$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/koah/opac-main.pl',
'status-code' => 200,
}
};
foreach my $csp_report_type ( 'report-uri', 'report-to' ) {
subtest "Test $csp_report_type" => sub {
plan tests => 15;
my $content_type = $csp_report_type eq 'report-to' ? 'application/csp-report' : 'application/reports+json';
$csp_report->{'csp-report'}->{'line-number'} = 99999999999999999;
# Test with standard CSP report-uri 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/koah/opac-main.pl',
'status-code' => 200,
}
};
if ( $csp_report_type eq 'report-to' ) {
$csp_report->{'body'} = delete $csp_report->{'csp-report'};
$csp_report->{'age'} = 1;
$csp_report->{'type'} = 'csp-violation';
$csp_report->{'url'} = 'https://library.example.org/cgi-bin/koha/opac-main.pl';
$csp_report->{'user_agent'} = 'Test::Mojo';
}
# Too large integers should be rejected
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
->status_is( 400, 'CSP report rejected (400) because of line-number exceeding maximum value' );
my $csp_report_body_key = $csp_report_type eq 'report-uri' ? 'csp-report' : 'body';
$csp_report->{'csp-report'}->{'line-number'} = -1;
$csp_report->{$csp_report_body_key}->{'line-number'} = 99999999999999999;
# Too small integers should be rejected
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
->status_is( 400, 'CSP report rejected (400) because of line-number not reaching minimum value' );
$csp_report->{'csp-report'}->{'line-number'} = 42;
# Too large integers should be rejected
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is( 400, 'CSP report rejected (400) because of line-number exceeding maximum value' );
$csp_report->{'csp-report'}->{'disposition'} = 'this is not okay';
$csp_report->{$csp_report_body_key}->{'line-number'} = -1;
# Enum values should be confirmed
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
->status_is( 400, 'CSP report rejected (400) because of disposition is not either "enforce" nor "report"' );
$csp_report->{'csp-report'}->{'disposition'} = 'enforce';
# Too small integers should be rejected
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is( 400, 'CSP report rejected (400) because of line-number not reaching minimum value' );
$csp_report->{$csp_report_body_key}->{'line-number'} = 42;
$csp_report->{'csp-report'}->{'script-sample'} =
'this is way too long script sample. a maximum of only 40 characters is allowed';
$csp_report->{$csp_report_body_key}->{'disposition'} = 'this is not okay';
# Too long strings should be rejected
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
->status_is( 400, 'CSP report rejected (400) because of script-sample exceeding maximum length' );
$csp_report->{'csp-report'}->{'script-sample'} = 'console.log("hi");';
# Enum values should be confirmed
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is(
400,
'CSP report rejected (400) because of disposition is not either "enforce" nor "report"'
);
$csp_report->{$csp_report_body_key}->{'disposition'} = 'enforce';
# 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' );
$csp_report->{$csp_report_body_key}->{'script-sample'} =
'this is way too long script sample. a maximum of only 40 characters is allowed';
# 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' );
# Too long strings should be rejected
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is( 400, 'CSP report rejected (400) because of script-sample exceeding maximum length' );
$csp_report->{$csp_report_body_key}->{'script-sample'} = 'console.log("hi");';
# 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',
}
};
# Anonymous request should work (browsers send these without auth)
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is( 204, 'CSP report accepted' );
$t->post_ok( '/api/v1/public/csp-reports' => json => $minimal_report )
->status_is( 204, 'Minimal CSP report accepted' );
# Test with application/json content type (also valid)
$t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is( 204, 'CSP report accepted with application/json content type' );
subtest 'make sure log entries are being written' => sub {
plan tests => 17;
# 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',
}
};
if ( $csp_report_type eq 'report-to' ) {
$minimal_report->{'body'} = delete $minimal_report->{'csp-report'};
$minimal_report->{'age'} = 1;
$minimal_report->{'type'} = 'csp-violation';
$minimal_report->{'url'} = 'https://library.example.org/cgi-bin/koha/opac-main.pl';
$minimal_report->{'user_agent'} = 'Test::Mojo';
}
my $log4perl_conf_file = C4::Context->config('intranetdir') . '/etc/log4perl.conf';
open my $fh, '<:encoding(UTF-8)', $log4perl_conf_file or do {
carp "Cannot read $log4perl_conf_file: $!";
return;
};
my $log4perl_conf_content = "";
while (<$fh>) {
my $line = $_;
next unless $line =~ /^(log4perl.logger.(plack-)?csp)|(log4perl.appender.(PLACK)?CSP)/;
$log4perl_conf_content .= "$line";
}
close $fh;
like( $log4perl_conf_content, qr/__LOG_DIR__\/csp-violations\.log/, 'csp-violations.log is defined' );
like(
$log4perl_conf_content, qr/__LOG_DIR__\/plack-csp-violations\.log/,
'plack-csp-violations.log is defined'
);
like(
$log4perl_conf_content, qr/log4perl.appender.CSP=Log::Log4perl::Appender::File/,
'CSP: File appender exists'
);
like(
$log4perl_conf_content, qr/log4perl.appender.PLACKCSP=Log::Log4perl::Appender::File/,
'PLACKCSP: File appender exists'
);
$log4perl_conf_content =~ s/Log::Log4perl::Appender::File/Log::Log4perl::Appender::TestBuffer/g;
Log::Log4perl::init( \$log4perl_conf_content );
my $appenders = Log::Log4perl->appenders;
my $appender = Log::Log4perl->appenders->{CSP};
my $appenderplack = Log::Log4perl->appenders->{PLACKCSP};
$t->post_ok(
'/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $minimal_report )
->status_is( 204, 'Minimal CSP report accepted' );
is( $appender->buffer, '', 'Nothing in log buffer yet' );
is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' );
subtest 'make sure log entries are being written' => sub {
plan tests => 17;
$t->post_ok(
'/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
->status_is( 204, 'CSP report accepted' );
my $log4perl_conf_file = C4::Context->config('intranetdir') . '/etc/log4perl.conf';
open my $fh, '<:encoding(UTF-8)', $log4perl_conf_file or do {
carp "Cannot read $log4perl_conf_file: $!";
return;
};
my $log4perl_conf_content = "";
while (<$fh>) {
my $line = $_;
next unless $line =~ /^(log4perl.logger.(plack-)?csp)|(log4perl.appender.(PLACK)?CSP)/;
$log4perl_conf_content .= "$line";
}
close $fh;
like( $log4perl_conf_content, qr/__LOG_DIR__\/csp-violations\.log/, 'csp-violations.log is defined' );
like(
$log4perl_conf_content, qr/__LOG_DIR__\/plack-csp-violations\.log/,
'plack-csp-violations.log is defined'
);
like(
$log4perl_conf_content, qr/log4perl.appender.CSP=Log::Log4perl::Appender::File/,
'CSP: File appender exists'
);
like(
$log4perl_conf_content, qr/log4perl.appender.PLACKCSP=Log::Log4perl::Appender::File/,
'PLACKCSP: File appender exists'
);
$log4perl_conf_content =~ s/Log::Log4perl::Appender::File/Log::Log4perl::Appender::TestBuffer/g;
Log::Log4perl::init( \$log4perl_conf_content );
my $appenders = Log::Log4perl->appenders;
my $appender = Log::Log4perl->appenders->{CSP};
my $appenderplack = Log::Log4perl->appenders->{PLACKCSP};
my $expected_log_entry = sprintf(
"CSP violation: '%s' blocked '%s' on page '%s'%s",
$csp_report->{'csp-report'}->{'violated-directive'},
$csp_report->{'csp-report'}->{'blocked-uri'},
$csp_report->{'csp-report'}->{'document-uri'},
' at '
. $csp_report->{'csp-report'}->{'source-file'} . ':'
. $csp_report->{'csp-report'}->{'line-number'} . ':'
. $csp_report->{'csp-report'}->{'column-number'}
);
is( $appender->buffer, '', 'Nothing in log buffer yet' );
is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' );
like(
$appender->buffer, qr/$expected_log_entry/
, 'Log contains expected log entry'
);
is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' );
$appender->clear();
$t->post_ok(
'/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is( 204, 'CSP report accepted' );
$ENV{'plack.is.enabled.for.this.test'} = 1; # tricking C4::Context->psgi_env
$t->post_ok(
'/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
->status_is( 204, 'CSP report accepted' );
like(
$appenderplack->buffer, qr/$expected_log_entry/
, 'Plack log contains expected log entry'
);
is( $appender->buffer, '', 'Nothing in log buffer yet' );
my $expected_log_entry = sprintf(
"CSP violation: '%s' blocked '%s' on page '%s'%s",
$csp_report->{$csp_report_body_key}->{'violated-directive'},
$csp_report->{$csp_report_body_key}->{'blocked-uri'},
$csp_report->{$csp_report_body_key}->{'document-uri'},
' at '
. $csp_report->{$csp_report_body_key}->{'source-file'} . ':'
. $csp_report->{$csp_report_body_key}->{'line-number'} . ':'
. $csp_report->{$csp_report_body_key}->{'column-number'}
);
Log::Log4perl::init(
\<<"HERE"
like(
$appender->buffer, qr/$expected_log_entry/
, 'Log contains expected log entry'
);
is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' );
$appender->clear();
$ENV{'plack.is.enabled.for.this.test'} = 1; # tricking C4::Context->psgi_env
$t->post_ok(
'/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is( 204, 'CSP report accepted' );
like(
$appenderplack->buffer, qr/$expected_log_entry/
, 'Plack log contains expected log entry'
);
is( $appender->buffer, '', 'Nothing in log buffer yet' );
Log::Log4perl::init(
\<<"HERE"
log4perl.logger.api = WARN, API
log4perl.appender.API=Log::Log4perl::Appender::TestBuffer
log4perl.appender.API.mode=append
@ -183,14 +208,20 @@ log4perl.appender.API.layout=PatternLayout
log4perl.appender.API.layout.ConversionPattern=[%d] [%p] %m %l%n
log4perl.appender.API.utf8=1
HERE
);
$appender = Log::Log4perl->appenders()->{API};
$t->post_ok(
'/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
->status_is( 204, 'CSP report returns 204 even when no CSP loggers are defined' );
is( $appender->buffer, '', 'Nothing in the only defined log buffer, because it is unrelated to CSP' );
);
$appender = Log::Log4perl->appenders()->{API};
$t->post_ok(
'/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report )
->status_is( 204, 'CSP report returns 204 even when no CSP loggers are defined' );
is( $appender->buffer, '', 'Nothing in the only defined log buffer, because it is unrelated to CSP' );
};
# clear buffers
$appender->clear();
$appenderplack->clear();
delete $ENV{'plack.is.enabled.for.this.test'};
};
};
}
$schema->storage->txn_rollback;
};