From 7b719c5ad9a3b961b34c1e60bbc838d224077691 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Wed, 26 Nov 2025 22:24:37 +0200 Subject: [PATCH] Bug 38365: Add Koha::Middleware::ContentSecurityPolicy This patch adds a new Perl package, Koha::Middleware::ContentSecurityPolicy. It is called for each Plack requests, and its job is to add the Content-Security-Policy[-Report-Only] header with the configured value. To test: 1. prove t/Koha/Middleware/ContentSecurityPolicy.t 2. prove t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t Signed-off-by: David Cook --- Koha/Middleware/ContentSecurityPolicy.pm | 56 ++++++ t/Koha/Middleware/ContentSecurityPolicy.t | 96 ++++++++++ .../Koha/Middleware/ContentSecurityPolicy.t | 164 ++++++++++++++++++ 3 files changed, 316 insertions(+) create mode 100644 Koha/Middleware/ContentSecurityPolicy.pm create mode 100755 t/Koha/Middleware/ContentSecurityPolicy.t create mode 100755 t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t diff --git a/Koha/Middleware/ContentSecurityPolicy.pm b/Koha/Middleware/ContentSecurityPolicy.pm new file mode 100644 index 00000000000..2f61747209a --- /dev/null +++ b/Koha/Middleware/ContentSecurityPolicy.pm @@ -0,0 +1,56 @@ +package Koha::Middleware::ContentSecurityPolicy; + +# 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 parent qw(Plack::Middleware); +use Plack::Request; +use Plack::Response; + +use Koha::ContentSecurityPolicy; + +=head1 METHODS + +=head2 call + +This method is called for each request, and adds the Content-Security-Policy or +the Content-Security-Policy-Report-Only header based on your CSP configuration in +koha-conf.xml. + +=cut + +sub call { + my ( $self, $env ) = @_; + + my $req = Plack::Request->new($env); + + my $csp = Koha::ContentSecurityPolicy->new; + + my $res = $self->app->($env); + return $res unless $csp->is_enabled; + + return Plack::Util::response_cb( + $res, + sub { + my $res = shift; + my $headers = $res->[1]; + push @$headers, ( $csp->header_name => $csp->header_value ); + } + ); +} + +1; diff --git a/t/Koha/Middleware/ContentSecurityPolicy.t b/t/Koha/Middleware/ContentSecurityPolicy.t new file mode 100755 index 00000000000..a03b3a7cdf4 --- /dev/null +++ b/t/Koha/Middleware/ContentSecurityPolicy.t @@ -0,0 +1,96 @@ +#!/usr/bin/perl + +# +# Copyright 2025 Hypernova Oy +# +# 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 strict; +use warnings; +use Test::NoWarnings; +use Test::More tests => 3; +use Test::Warn; + +use HTTP::Request::Common; +use Plack::Builder; +use Plack::Util; +use Plack::Test; + +use t::lib::Mocks; + +use_ok("Koha::Middleware::ContentSecurityPolicy"); + +my $conf_csp_section = 'content_security_policy'; + +subtest 'test Content-Security-Policy header' => sub { + plan tests => 3; + + my $test_nonce = 'TEST_NONCE'; + my $csp_header_value = + "default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; img-src 'self' data:; font-src 'self'; object-src 'none'"; + + t::lib::Mocks::mock_config( + $conf_csp_section, + { opac => { csp_mode => 'enabled', csp_header_value => $csp_header_value } } + ); + + # Set nonce + Koha::ContentSecurityPolicy->new->nonce($test_nonce); + + my $env = {}; + my $app = sub { + my $resp = [ + 200, + [ + 'Content-Type', + 'text/plain', + 'Content-Length', + 12 + ], + [ Koha::ContentSecurityPolicy->new->nonce ] + ]; + return $resp; + }; + + $app = builder { + enable "+Koha::Middleware::ContentSecurityPolicy"; + $app; + }; + + my $test = Plack::Test->create($app); + my $res = $test->request( GET "/" ); + my $expected_csp_header_value = $csp_header_value; + $expected_csp_header_value =~ s/_CSP_NONCE_/$test_nonce/g; + is( + $res->header('content-security-policy'), $expected_csp_header_value, + "Response contains Content-Security-Policy header with the expected value" + ); + is( + $res->content, $test_nonce, + "Response contains generated nonce in the body" + ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { opac => { csp_mode => '', csp_header_value => $csp_header_value } } + ); + + $res = $test->request( GET "/" ); + is( + $res->header('content-security-policy'), undef, + "Response does not contain Content-Security-Policy header when it is disabled in koha-conf.xml" + ); +}; diff --git a/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t b/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t new file mode 100755 index 00000000000..4c768217f3a --- /dev/null +++ b/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t @@ -0,0 +1,164 @@ +#!/usr/bin/perl + +# +# Copyright 2025 Hypernova Oy +# +# 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 strict; +use warnings; +use Test::NoWarnings; +use Test::More tests => 4; + +use HTTP::Request::Common; +use Plack::App::CGIBin; +use Plack::Builder; +use Plack::Test; + +use t::lib::Mocks; + +use_ok("Koha::Middleware::ContentSecurityPolicy"); + +my $conf_csp_section = 'content_security_policy'; + +subtest 'test CSP in OPAC' => sub { + plan tests => 5; + + my $test_nonce = 'TEST_NONCE'; + my $csp_header_value = + "default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; img-src 'self' data:; font-src 'self'; object-src 'none'"; + + t::lib::Mocks::mock_preference( 'OpacPublic', 1 ); + t::lib::Mocks::mock_config( + $conf_csp_section, + { opac => { csp_mode => 'enabled', csp_header_value => $csp_header_value } } + ); + + # Set nonce + Koha::ContentSecurityPolicy->new->nonce($test_nonce); + + my $env = {}; + my $home = $ENV{KOHA_HOME}; + my $app = Plack::App::CGIBin->new( root => $ENV{GIT_INSTALL} ? "$home/opac" : "$home/opac/cgi-bin/opac" )->to_app; + + $app = builder { + mount '/opac' => builder { + enable "+Koha::Middleware::ContentSecurityPolicy"; + $app; + }; + }; + + my $test = Plack::Test->create($app); + my $res = $test->request( GET "/opac/opac-main.pl" ); + my $expected_csp_header_value = $csp_header_value; + $expected_csp_header_value =~ s/_CSP_NONCE_/$test_nonce/g; + is( + $res->header('content-security-policy'), $expected_csp_header_value, + "Response contains Content-Security-Policy header with the expected value" + ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { opac => { csp_mode => '', csp_header_value => $csp_header_value } } + ); + + $res = $test->request( GET "/opac/opac-main.pl" ); + is( + $res->header('content-security-policy'), undef, + "Response does not contain Content-Security-Policy header when it is disabled in koha-conf.xml" + ); + + like( $res->content, qr/request( GET "/opac/opac-main.pl" ); + is( + $res->header('content-security-policy'), undef, + "Response does not contain Content-Security-Policy header when it has not been defined in koha-conf.xml" + ); + + like( $res->content, qr/ sub { + plan tests => 5; + + my $test_nonce = 'TEST_NONCE'; + my $csp_header_value = + "default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; img-src 'self' data:; font-src 'self'; object-src 'none'"; + + t::lib::Mocks::mock_config( + $conf_csp_section, + { opac => { csp_mode => 'enabled', csp_header_value => $csp_header_value } } + ); + + # Set nonce + Koha::ContentSecurityPolicy->new->nonce($test_nonce); + + my $env = {}; + my $home = $ENV{KOHA_HOME}; + my $app = Plack::App::CGIBin->new( root => $ENV{GIT_INSTALL} ? $home : "$home/intranet/cgi-bin/" )->to_app; + + $app = builder { + mount '/intranet' => builder { + enable "+Koha::Middleware::ContentSecurityPolicy"; + $app; + }; + }; + + my $test = Plack::Test->create($app); + my $res = $test->request( GET "/intranet/mainpage.pl" ); + my $expected_csp_header_value = $csp_header_value; + $expected_csp_header_value =~ s/_CSP_NONCE_/$test_nonce/g; + is( + $res->header('content-security-policy'), undef, + "Response does not Content-Security-Policy header when it has not been defined in koha-conf.xml" + ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { + intranet => { + csp_mode => '', csp_header_value => $csp_header_value, + } + } + ); + + $res = $test->request( GET "/intranet/mainpage.pl" ); + is( + $res->header('content-security-policy'), undef, + "Response does not contain Content-Security-Policy header when it is explicitly disabled in koha-conf.xml" + ); + + like( $res->content, qr/ { csp_mode => 'enabled', csp_header_value => $csp_header_value } } + ); + + $res = $test->request( GET "/intranet/mainpage.pl" ); + is( + $res->header('content-security-policy'), $expected_csp_header_value, + "Response contains Content-Security-Policy header when it is enabled in koha-conf.xml" + ); + + like( $res->content, qr/