From ca5a468d185bef82589cfce4fcc0625fa501c7f4 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Thu, 5 Feb 2026 16:42:57 +0200 Subject: [PATCH] Bug 38365: Require a token for using public CSP reports endpoint This patch protects the endpoint with a token that is valid for only 15 seconds, so that one can't simply keep POST'ing indefinitely, filling CSP violation logs. To test: 1. prove t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t 2. prove t/db_dependent/api/v1/public/csp_reports.t --- Koha/Middleware/ContentSecurityPolicy.pm | 17 ++++++- Koha/REST/V1/CSPReports.pm | 32 +++++++++++++ api/v1/swagger/paths/public_csp_reports.yaml | 10 ++++ .../Koha/Middleware/ContentSecurityPolicy.t | 15 +++++- t/db_dependent/api/v1/public/csp_reports.t | 47 +++++++++++++++---- 5 files changed, 108 insertions(+), 13 deletions(-) diff --git a/Koha/Middleware/ContentSecurityPolicy.pm b/Koha/Middleware/ContentSecurityPolicy.pm index 49fe6379926..c4f35c50108 100644 --- a/Koha/Middleware/ContentSecurityPolicy.pm +++ b/Koha/Middleware/ContentSecurityPolicy.pm @@ -18,6 +18,9 @@ package Koha::Middleware::ContentSecurityPolicy; use Modern::Perl; use parent qw(Plack::Middleware); +use Encode; +use Digest::MD5; +use Mojo::JWT; use Plack::Request; use Plack::Response; @@ -76,7 +79,19 @@ sub _add_csp_to_reporting_endpoints { } else { $value = ''; } - $value = $value . 'csp-violations="/api/v1/public/csp-reports"'; + + my $time = time(); + my $token = Mojo::JWT->new( + claims => { + type => 'csp-violation', + timestamp => $time, + }, + expires => $time + 15, # the endpoint is valid for 15 seconds + secret => Digest::MD5::md5_base64( + Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' ) + ) + )->encode; + $value = $value . 'csp-violations="/api/v1/public/csp/report-uri?token=' . $token . '"'; return $value; } diff --git a/Koha/REST/V1/CSPReports.pm b/Koha/REST/V1/CSPReports.pm index 0040208852b..eebef5f4b55 100644 --- a/Koha/REST/V1/CSPReports.pm +++ b/Koha/REST/V1/CSPReports.pm @@ -20,9 +20,12 @@ package Koha::REST::V1::CSPReports; use Modern::Perl; use Mojo::Base 'Mojolicious::Controller'; +use Mojo::JWT; use Koha::Logger; +use Try::Tiny qw( try catch ); + =head1 NAME Koha::REST::V1::CSPReports - Controller for Content-Security-Policy violation reports @@ -46,6 +49,35 @@ is violated. This endpoint logs those reports for administrator review. sub add { my $c = shift->openapi->valid_input or return; + my $token = $c->param('token'); + my $decoded_jwt; + try { + $decoded_jwt = Mojo::JWT->new( + secret => Digest::MD5::md5_base64( + Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' ) + ) + )->decode($token); + } catch { + }; + + return $c->render( + status => 401, + openapi => { error => 'Invalid token.' } + ) unless $decoded_jwt; + + if ( !$decoded_jwt->{timestamp} || $decoded_jwt->{timestamp} + 15 < time() ) { + return $c->render( + status => 400, + openapi => { error => 'This token has expired.' } + ); + } + if ( !$decoded_jwt->{type} || $decoded_jwt->{type} ne 'csp-violation' ) { + return $c->render( + status => 400, + openapi => { error => 'Invalid token type.' } + ); + } + my $report = $c->req->json; # CSP reports come wrapped in a 'csp-report' key diff --git a/api/v1/swagger/paths/public_csp_reports.yaml b/api/v1/swagger/paths/public_csp_reports.yaml index 735b5c81ca4..ccf4746568d 100644 --- a/api/v1/swagger/paths/public_csp_reports.yaml +++ b/api/v1/swagger/paths/public_csp_reports.yaml @@ -26,6 +26,12 @@ required: true schema: $ref: "../swagger.yaml#/definitions/csp_report" + - name: token + in: query + description: Temporary token found in Reporting-Endpoints response header + required: true + type: string + minLength: 4 responses: "204": description: Report received successfully @@ -33,6 +39,10 @@ description: Bad request schema: $ref: "../swagger.yaml#/definitions/error" + "401": + description: Invalid token + schema: + $ref: "../swagger.yaml#/definitions/error" "500": description: Internal server error schema: diff --git a/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t b/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t index 7d12f3cd9c4..04279f930b8 100755 --- a/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t +++ b/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t @@ -165,7 +165,7 @@ subtest 'test CSP in staff client' => sub { }; subtest 'test Reporting-Endpoints for CSP violation reports' => sub { - plan tests => 1; + plan tests => 4; my $test_nonce = 'TEST_NONCE'; my $csp_header_value = @@ -200,9 +200,20 @@ subtest 'test Reporting-Endpoints for CSP violation reports' => sub { my $expected_csp_header_value = $csp_header_value; $expected_csp_header_value =~ s/_CSP_NONCE_/$test_nonce/g; like( - $res->header('reporting-endpoints'), qr/^csp-violations="\/api\/v1\/public\/csp-reports"$/, + $res->header('reporting-endpoints'), qr/^csp-violations="\/api\/v1\/public\/csp\/report-uri\?token=[\w\.\-]+"$/, "Response contains Reporting-Endpoints header with the expected value" ); + my ($token) = $res->header('reporting-endpoints') =~ /token=([\w\.\-]+)"$/; + + my $decoded_jwt = Mojo::JWT->new( + secret => Digest::MD5::md5_base64( + Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' ) + ) + )->decode($token); + + cmp_ok( $decoded_jwt->{timestamp}, '<=', time(), 'JWT timestamp is less or equal to current timestamp' ); + cmp_ok( $decoded_jwt->{exp}, '>=', time(), 'JWT expires in the future' ); + is( $decoded_jwt->{type}, 'csp-violation', 'JWT type is csp-violation' ); $schema->storage->txn_rollback; }; diff --git a/t/db_dependent/api/v1/public/csp_reports.t b/t/db_dependent/api/v1/public/csp_reports.t index 40da813de6a..2a910baf997 100755 --- a/t/db_dependent/api/v1/public/csp_reports.t +++ b/t/db_dependent/api/v1/public/csp_reports.t @@ -33,10 +33,11 @@ my $schema = Koha::Database->new->schema; my $t = Test::Mojo->new('Koha::REST::V1'); t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); +t::lib::Mocks::mock_config( 'api_secret_passphrase', 'test' ); subtest 'add() tests' => sub { - plan tests => 7; + plan tests => 13; $schema->storage->txn_begin; @@ -57,12 +58,24 @@ subtest 'add() tests' => sub { } }; - # Anonymous request should work (browsers send these without auth) + # Anonymous request requires token $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report ) + ->status_is( 400, 'CSP report denied (400)' ) + ->content_like( qr/Missing property.*\/token"/, '...because of a missing token' ); + + # Anonymous request requires valid token + $t->post_ok( '/api/v1/public/csp-reports?token=invalid' => { 'Content-Type' => 'application/csp-report' } => json => + $csp_report )->status_is( 401, 'CSP report denied (401)' ) + ->json_is( '/error', 'Invalid token.', '...because of an invalid token' ); + + # Anonymous request should work (browsers send these without auth) + $t->post_ok( '/api/v1/public/csp-reports?token=' + . csp_token() => { '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 ) + $t->post_ok( '/api/v1/public/csp-reports?token=' + . csp_token() => { 'Content-Type' => 'application/json' } => json => $csp_report ) ->status_is( 204, 'CSP report accepted with application/json content type' ); # Test with minimal report @@ -74,7 +87,7 @@ subtest 'add() tests' => sub { } }; - $t->post_ok( '/api/v1/public/csp-reports' => json => $minimal_report ) + $t->post_ok( '/api/v1/public/csp-reports?token=' . csp_token() => json => $minimal_report ) ->status_is( 204, 'Minimal CSP report accepted' ); subtest 'make sure log entries are being written' => sub { @@ -114,8 +127,8 @@ subtest 'add() tests' => sub { 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 ) + $t->post_ok( '/api/v1/public/csp-reports?token=' + . csp_token() => { 'Content-Type' => 'application/csp-report' } => json => $csp_report ) ->status_is( 204, 'CSP report accepted' ); my $expected_log_entry = sprintf( @@ -137,8 +150,8 @@ subtest 'add() tests' => sub { $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 ) + $t->post_ok( '/api/v1/public/csp-reports?token=' + . csp_token() => { 'Content-Type' => 'application/csp-report' } => json => $csp_report ) ->status_is( 204, 'CSP report accepted' ); like( $appenderplack->buffer, qr/$expected_log_entry/ @@ -157,8 +170,8 @@ 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 ) + $t->post_ok( '/api/v1/public/csp-reports?token=' + . csp_token() => { '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' ); @@ -167,4 +180,18 @@ HERE $schema->storage->txn_rollback; }; +sub csp_token { + my $time = time(); + return Mojo::JWT->new( + claims => { + type => 'csp-violation', + timestamp => $time, + }, + expires => $time + 15, # the endpoint is valid for 15 seconds + secret => Digest::MD5::md5_base64( + Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' ) + ) + )->encode; +} + 1; -- 2.34.1