From 5d97e0ba3fa8bf46281f4218660e1a6e5ae98515 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Mon, 27 Oct 2025 20:31:12 +0200 Subject: [PATCH] Bug 38365: Add Koha::ContentSecurityPolicy This patch adds a new Perl package, Koha::ContentSecurityPolicy. It provides the following methods: $csp->header_name Returns the name of the CSP header, based on koha-conf configuration. $csp->header_value Returns the value of the CSP header, based on koha-conf configuration. $csp->is_enabled Returns true or false, based on koha-conf configuration. $csp->nonce Generates or sets, and returns the nonce. A CSP nonce is a random token that is used both in the inline scripts and the Content-Security-Policy[-Report-Only] response header. To test: 1. prove t/Koha/ContentSecurityPolicy.t Signed-off-by: David Cook Signed-off-by: Martin Renvoize --- Koha/ContentSecurityPolicy.pm | 209 +++++++++++++++++++++++++++++++++ t/Koha/ContentSecurityPolicy.t | 178 ++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 Koha/ContentSecurityPolicy.pm create mode 100755 t/Koha/ContentSecurityPolicy.t diff --git a/Koha/ContentSecurityPolicy.pm b/Koha/ContentSecurityPolicy.pm new file mode 100644 index 00000000000..b88ea6200cc --- /dev/null +++ b/Koha/ContentSecurityPolicy.pm @@ -0,0 +1,209 @@ +package Koha::ContentSecurityPolicy; + +# Copyright 2025 Hypernova Oy, Koha Development Team +# +# 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 C4::Context; +use Koha::Cache::Memory::Lite; +use Koha::Token; + +use Koha::Exceptions::Config; + +=head1 NAME + +Koha::ContentSecurityPolicy - Object for handling Content-Security-Policy header + +=head1 SYNOPSIS + + use Koha::ContentSecurityPolicy; + + my $csp = Koha::ContentSecurityPolicy->new; + + if ($csp->is_enabled) { + $options->{$csp->header_name} = $csp->header_value; + } + + print $cgi->header($options), $data; + +=head1 DESCRIPTION + +TODO + +=head1 METHODS + +=head2 new + + my $csp = Koha::ContentSecurityPolicy->new; + +=cut + +sub new { + my ( $class, $params ) = @_; + my $self = bless $params // {}, $class; + + $self->nonce( $params->{nonce} ); + $self->{config} = Koha::Config->get_instance; + return $self; +} + +=head2 header_name + + $csp->header_name($args); + + Given C<$args>, returns the name of the CSP header. + + C<$args> can contain the following keys (and values) + + - interface: + - defaults to L->interface. + - can be one of: C, C + + Returns 'Content-Security-Policy' if CSP is in "enabled" csp_mode + Returns 'Content-Security-Policy-Report-Only' if CSP in "report-only" csp_mode + + Throws Koha::Exceptions::Config::MissingEntry is CSP csp_mode is disabled in KOHA_CONF + +=cut + +sub header_name { + my ( $self, $args ) = @_; + + my $interface //= $args->{interface} || C4::Context->interface; + + my $conf_csp = $self->{config}->get('content_security_policy'); + + Koha::Exceptions::Config::MissingEntry->throw( error => 'Missing request content_security_policy csp_header_value' ) + unless exists $conf_csp->{$interface}->{csp_mode}; + + return 'Content-Security-Policy-Report-Only' if $conf_csp->{$interface}->{csp_mode} eq 'report-only'; + return 'Content-Security-Policy' if $conf_csp->{$interface}->{csp_mode} eq 'enabled'; + + Koha::Exceptions::Config::MissingEntry->throw( + error => 'Content Security Policy is disabled. Header name should only be retrieved when CSP is enabled.' ); +} + +=head2 header_value + + $csp->header_value($args); + + Given C<$args>, returns the value of the CSP header. + + C<$args> can contain the following keys (and values) + + - interface: + - defaults to L->interface. + - can be one of: C, C + + Returns content_security_policy.[opac|staff].csp_header_value + + Throws Koha::Exceptions::Config::MissingEntry is CSP property "csp_header_value" is missing in KOHA_CONF + +=cut + +sub header_value { + my ( $self, $args ) = @_; + + my $interface //= $args->{interface} || C4::Context->interface; + + my $conf_csp = $self->{config}->get('content_security_policy'); + + Koha::Exceptions::Config::MissingEntry->throw( error => 'Missing request content_security_policy csp_header_value' ) + unless exists $conf_csp->{$interface}->{csp_header_value}; + + my $csp_header_value = $conf_csp->{$interface}->{csp_header_value}; + my $csp_nonce = $self->nonce; + + $csp_header_value =~ s/_CSP_NONCE_/$csp_nonce/g; + + return $csp_header_value; +} + +=head2 is_enabled + + $csp->is_enabled($args); + + Given C<$args>, checks if CSP is enabled + + C<$args> can contain the following keys (and values) + + - interface: + - defaults to L->interface. + - can be one of: C, C + + Returns 0 if CSP is disabled for given C<$args> + Returns 1 if CSP is enabled for given C<$args> + +=cut + +sub is_enabled { + my ( $self, $args ) = @_; + my $interface //= $args->{interface} || C4::Context->interface; + + my $conf_csp = $self->{config}->get('content_security_policy'); + + return 0 if $interface ne 'opac' && $interface ne 'intranet'; + + return 0 unless exists $conf_csp->{$interface}->{csp_mode}; + return 1 + if $conf_csp->{$interface}->{csp_mode} eq 'report-only' + or $conf_csp->{$interface}->{csp_mode} eq 'enabled'; + return 0; +} + +=head2 nonce + + $csp->nonce(); + + Generates and returns the nonce. + + $csp->nonce($nonce); + + Sets and returns the nonce. + + A CSP nonce is a random token that is used both in the inline scripts + and the Content-Security-Policy[-Report-Only] response header. + +=cut + +sub nonce { + my ( $self, $nonce ) = @_; + my $cache = Koha::Cache::Memory::Lite->new; + + if ($nonce) { + $cache->set_in_cache( 'CSP-NONCE', $nonce ); + $self->{nonce} = $nonce; + return $self->{nonce}; + } + + if ( $nonce = $cache->get_from_cache('CSP-NONCE') ) { + $self->{nonce} = $nonce; + return $self->{nonce}; + } + + unless ( $self->{nonce} ) { + $nonce = Koha::Token->new()->generate( { pattern => '\w{10}' } ); + $self->{nonce} = $nonce; + } + + $cache->set_in_cache( 'CSP-NONCE', $self->{nonce} ); + + return $self->{nonce}; +} + +1; diff --git a/t/Koha/ContentSecurityPolicy.t b/t/Koha/ContentSecurityPolicy.t new file mode 100755 index 00000000000..b8d01737de1 --- /dev/null +++ b/t/Koha/ContentSecurityPolicy.t @@ -0,0 +1,178 @@ +#!/usr/bin/perl + +# 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 Test::NoWarnings; +use Test::MockModule; +use Test::More tests => 6; +use Test::Exception; + +BEGIN { use_ok('Koha::ContentSecurityPolicy') } + +use C4::Context; +use Koha::Cache::Memory::Lite; + +use t::lib::Mocks; + +my $conf_csp_section = 'content_security_policy'; + +subtest 'is_enabled() tests' => sub { + + plan tests => 4; + + foreach my $interface ( 'opac', 'intranet' ) { + subtest "interface $interface" => sub { + t::lib::Mocks::mock_config( $conf_csp_section, {} ); + C4::Context->interface($interface); + + my $csp = Koha::ContentSecurityPolicy->new; + + is( $csp->is_enabled, 0, 'CSP is not enabled when koha-conf.xml has not set the entire section' ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { $interface => { csp_mode => '', csp_header_value => 'test' } } + ); + is( $csp->is_enabled, 0, 'CSP is not enabled when csp_mode is empty' ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { $interface => { csp_mode => 'wrong', csp_header_value => 'test' } } + ); + is( $csp->is_enabled, 0, 'CSP is not enabled when csp_mode has not got a valid csp_header_value' ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { $interface => { csp_mode => 'report-only', csp_header_value => 'test' } } + ); + is( $csp->is_enabled, 1, 'CSP is enabled when csp_mode is report-only' ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { $interface => { csp_mode => 'enabled', csp_header_value => 'test' } } + ); + is( $csp->is_enabled, 1, 'CSP is enabled when csp_mode is enabled' ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { + $interface => { + csp_mode => 'enabled', csp_header_value => 'test', + } + } + ); + }; + } + + t::lib::Mocks::mock_config( + $conf_csp_section, { opac => { csp_mode => 'enabled', csp_header_value => 'test' } }, + intranet => { csp_mode => '', csp_header_value => 'test' } + ); + C4::Context->interface('intranet'); + my $csp = Koha::ContentSecurityPolicy->new; + is( $csp->is_enabled, 0, 'CSP is disabled when other interface is enabled, but not this one' ); + is( $csp->is_enabled( { interface => 'opac' } ), 1, 'CSP is enabled when explicitly defining interface' ); +}; + +subtest 'header_name tests' => sub { + plan tests => 6; + + t::lib::Mocks::mock_config( $conf_csp_section, {} ); + C4::Context->interface('opac'); + + my $csp = Koha::ContentSecurityPolicy->new; + + throws_ok { $csp->header_name } 'Koha::Exceptions::Config::MissingEntry', 'Exception thrown when missing csp_mode'; + + t::lib::Mocks::mock_config( $conf_csp_section, { opac => {} } ); + throws_ok { $csp->header_name } 'Koha::Exceptions::Config::MissingEntry', 'Exception thrown when missing csp_mode'; + + t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_mode => '' } } ); + throws_ok { $csp->header_name } 'Koha::Exceptions::Config::MissingEntry', 'Exception thrown when missing csp_mode'; + t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_mode => 'invalid' } } ); + throws_ok { $csp->header_name } 'Koha::Exceptions::Config::MissingEntry', 'Exception thrown when invalid csp_mode'; + + t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_mode => 'report-only' } } ); + is( + $csp->header_name, 'Content-Security-Policy-Report-Only', + 'report-only => Content-Security-Policy-Report-Only' + ); + t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_mode => 'enabled' } } ); + is( $csp->header_name, 'Content-Security-Policy', 'enabled => Content-Security-Policy' ); +}; + +subtest 'header_value tests' => sub { + plan tests => 6; + + t::lib::Mocks::mock_config( $conf_csp_section, {} ); + C4::Context->interface('opac'); + + my $csp = Koha::ContentSecurityPolicy->new; + + throws_ok { $csp->header_value } 'Koha::Exceptions::Config::MissingEntry', + 'Exception thrown when missing csp_header_value'; + + t::lib::Mocks::mock_config( $conf_csp_section, { opac => {} } ); + throws_ok { $csp->header_value } 'Koha::Exceptions::Config::MissingEntry', + 'Exception thrown when missing csp_header_value'; + + t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_header_value => '' } } ); + is( $csp->header_value, '', 'Even an empty csp_header_value is retrieved form koha-conf.xml' ); + t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_header_value => 'some value' } } ); + is( $csp->header_value, 'some value', 'csp_header_value is retrieved from koha-conf.xml' ); + + t::lib::Mocks::mock_config( + $conf_csp_section, + { opac => { csp_header_value => 'begin nonce-_CSP_NONCE_ end' } } + ); + my $cache = Koha::Cache::Memory::Lite->get_instance(); + $cache->set_in_cache( 'CSP-NONCE', 'cached value' ); + is( $csp->header_value, 'begin nonce-cached value end', 'Cached csp_header_value' ); + $cache->flush; + + $csp = Koha::ContentSecurityPolicy->new( { nonce => 'forced value' } ); + is( $csp->header_value, 'begin nonce-forced value end', 'Forced csp_header_value' ); +}; + +subtest 'nonce tests' => sub { + plan tests => 5; + + t::lib::Mocks::mock_config( $conf_csp_section, {} ); + C4::Context->interface('opac'); + + my $csp = Koha::ContentSecurityPolicy->new; + + $csp = Koha::ContentSecurityPolicy->new( { nonce => 'forced value' } ); + is( $csp->nonce, 'forced value', 'nonce is not re-generated as it was passed to new()' ); + + $csp = Koha::ContentSecurityPolicy->new( { nonce => 'forced value' } ); + is( $csp->nonce, 'forced value', 'nonce is not re-generated as was cached in memory' ); + + my $cache = Koha::Cache::Memory::Lite->get_instance(); + $cache->flush; + + $csp = Koha::ContentSecurityPolicy->new(); + my $nonce = $csp->nonce; + like( $nonce, qr/\w{10}/, 'nonce is a random string of 10 characters' ); + is( $nonce, $csp->nonce, 're-calling nonce() returns the same randomly generated cached value' ); + + $cache->set_in_cache( 'CSP-NONCE', 'cached value' ); + is( $csp->nonce, 'cached value', 'nonce is not re-generated as it was previously cached' ); +}; + +1; -- 2.52.0