From 5140e5ca1db9a484b004f96af59935fce9547d29 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 To test: 1. prove t/Koha/ContentSecurityPolicy.t Signed-off-by: David Cook --- Koha/ContentSecurityPolicy.pm | 173 +++++++++++++++++++++++++++++++++ t/Koha/ContentSecurityPolicy.t | 138 ++++++++++++++++++++++++++ 2 files changed, 311 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..048bd2e5a9b --- /dev/null +++ b/Koha/ContentSecurityPolicy.pm @@ -0,0 +1,173 @@ +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::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; + + my $config = Koha::Config->get_instance; + + $self->{config} = $config; + $self->{nonce} = $params->{nonce}; + return $self; +} + +=head2 header_name + + $csp->header_name($interface); + + Returns the name of the CSP header for given $interface. + + $interface can be one of: undef, 'opac', 'intranet' + $interface defaults to C4::Context->interface. + + 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.xml + +=cut + +sub header_name { + my ( $self, $interface ) = @_; + + $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_mode' ) + 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($interface); + + Returns the value of the CSP header for given $interface. + + $interface can be one of: undef, 'opac', 'intranet' + $interface defaults to C4::Context->interface. + + Returns content_security_policy.[opac|staff].csp_header_value, depending on $interface + + Throws Koha::Exceptions::Config::MissingEntry is CSP property "csp_header_value" is missing in koha-conf.xml + +=cut + +sub header_value { + my ( $self, $interface ) = @_; + + $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 $cache = Koha::Cache::Memory::Lite->get_instance(); + my $csp_nonce = ''; + if ( defined $self->{nonce} ) { + $csp_nonce = $self->{nonce}; + } elsif ( my $nonce = $cache->get_from_cache("csp_nonce") ) { + $csp_nonce = $nonce if defined $nonce; + } + if ($csp_nonce) { + $csp_header_value =~ s/_CSP_NONCE_/nonce-$csp_nonce/g; + } elsif ( $csp_nonce eq '' ) { + $csp_header_value =~ s/_CSP_NONCE_//g; + } + + return $csp_header_value; +} + +=head2 is_enabled + + $csp->is_enabled($interface); + + Checks if CSP is enabled for given $interface. + + $interface defaults to C4::Context->interface. + $interface can be one of: 'opac', 'intranet' + + Returns 0 if CSP is disabled for this interface + Returns 1 if CSP is enabled for this interface + +=cut + +sub is_enabled { + my ( $self, $interface ) = @_; + + $interface //= C4::Context->interface; + + return 0 if $interface ne 'opac' && $interface ne 'intranet'; + + my $conf_csp = $self->{config}->get('content_security_policy'); + + return 0 unless $conf_csp; + return 0 unless exists $conf_csp->{$interface}; + 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; +} + +1; diff --git a/t/Koha/ContentSecurityPolicy.t b/t/Koha/ContentSecurityPolicy.t new file mode 100755 index 00000000000..0fad1a1bfaa --- /dev/null +++ b/t/Koha/ContentSecurityPolicy.t @@ -0,0 +1,138 @@ +#!/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::More tests => 5; +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, { 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('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 _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' ); + + $csp = Koha::ContentSecurityPolicy->new( { nonce => 'forced value' } ); + is( $csp->header_value, 'begin nonce-forced value end', 'forced csp_header_value' ); +}; + +1; -- 2.39.5