From 5999eefd7ccc41d79f347d7c63eb408221e514a8 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Sun, 8 Feb 2026 11:27:34 +0200 Subject: [PATCH] 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 --- Koha/REST/V1/CSPReports.pm | 2 +- api/v1/swagger/definitions/csp_report.yaml | 112 ++++--- api/v1/swagger/paths/public_csp_reports.yaml | 3 +- api/v1/swagger/swagger.yaml | 2 + t/db_dependent/api/v1/public/csp_reports.t | 321 ++++++++++--------- 5 files changed, 235 insertions(+), 205 deletions(-) diff --git a/Koha/REST/V1/CSPReports.pm b/Koha/REST/V1/CSPReports.pm index 0040208852b..38ae99ed913 100644 --- a/Koha/REST/V1/CSPReports.pm +++ b/Koha/REST/V1/CSPReports.pm @@ -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' } ); diff --git a/api/v1/swagger/definitions/csp_report.yaml b/api/v1/swagger/definitions/csp_report.yaml index 82bf9176ce8..fd0eae54ae6 100644 --- a/api/v1/swagger/definitions/csp_report.yaml +++ b/api/v1/swagger/definitions/csp_report.yaml @@ -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 \ No newline at end of file diff --git a/api/v1/swagger/paths/public_csp_reports.yaml b/api/v1/swagger/paths/public_csp_reports.yaml index 735b5c81ca4..44443dc254e 100644 --- a/api/v1/swagger/paths/public_csp_reports.yaml +++ b/api/v1/swagger/paths/public_csp_reports.yaml @@ -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 diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index afd3e0331ea..6eb5b3a2716 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -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: diff --git a/t/db_dependent/api/v1/public/csp_reports.t b/t/db_dependent/api/v1/public/csp_reports.t index 35862f107e8..951322daf08 100755 --- a/t/db_dependent/api/v1/public/csp_reports.t +++ b/t/db_dependent/api/v1/public/csp_reports.t @@ -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, - } - }; - - $csp_report->{'csp-report'}->{'line-number'} = 99999999999999999; - - # 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' ); - - $csp_report->{'csp-report'}->{'line-number'} = -1; - - # 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; - - $csp_report->{'csp-report'}->{'disposition'} = 'this is not okay'; - - # 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'; - - $csp_report->{'csp-report'}->{'script-sample'} = - 'this is way too long script sample. a maximum of only 40 characters is allowed'; - - # 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");'; - - # 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' ); - - subtest 'make sure log entries are being written' => sub { - plan tests => 17; - - 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}; - - is( $appender->buffer, '', 'Nothing in log buffer yet' ); - is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' ); - - $t->post_ok( - '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report ) - ->status_is( 204, 'CSP report accepted' ); - - 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'} - ); - - 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' => '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' ); - - Log::Log4perl::init( - \<<"HERE" + 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'; + + # 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'; + } + + my $csp_report_body_key = $csp_report_type eq 'report-uri' ? 'csp-report' : 'body'; + + $csp_report->{$csp_report_body_key}->{'line-number'} = 99999999999999999; + + # 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_body_key}->{'line-number'} = -1; + + # 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_body_key}->{'disposition'} = 'this is not okay'; + + # 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'; + + $csp_report->{$csp_report_body_key}->{'script-sample'} = + 'this is way too long script sample. a maximum of only 40 characters is allowed'; + + # 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");'; + + # 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' ); + + # 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' ); + + # 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'; + } + + $t->post_ok( + '/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $minimal_report ) + ->status_is( 204, 'Minimal CSP report accepted' ); + + subtest 'make sure log entries are being written' => sub { + plan tests => 17; + + 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}; + + is( $appender->buffer, '', 'Nothing in log buffer yet' ); + is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' ); + + $t->post_ok( + '/api/v1/public/csp-reports' => { 'Content-Type' => $content_type } => json => $csp_report ) + ->status_is( 204, 'CSP report accepted' ); + + 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'} + ); + + 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; }; -- 2.39.5