View | Details | Raw Unified | Return to bug 38365
Collapse All | Expand All

(-)a/Koha/ContentSecurityPolicy.pm (+173 lines)
Line 0 Link Here
1
package Koha::ContentSecurityPolicy;
2
3
# Copyright 2025 Hypernova Oy, Koha Development Team
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Koha is distributed in the hope that it will be useful, but
13
# WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with Koha; if not, see <https://www.gnu.org/licenses>.
19
20
use Modern::Perl;
21
22
use C4::Context;
23
use Koha::Cache::Memory::Lite;
24
25
use Koha::Exceptions::Config;
26
27
=head1 NAME
28
29
Koha::ContentSecurityPolicy - Object for handling Content-Security-Policy header
30
31
=head1 SYNOPSIS
32
33
    use Koha::ContentSecurityPolicy;
34
35
    my $csp = Koha::ContentSecurityPolicy->new;
36
37
    if ($csp->is_enabled) {
38
        $options->{$csp->header_name} = $csp->header_value;
39
    }
40
41
    print $cgi->header($options), $data;
42
43
=head1 DESCRIPTION
44
45
TODO
46
47
=head1 METHODS
48
49
=head2 new
50
51
    my $csp = Koha::ContentSecurityPolicy->new;
52
53
=cut
54
55
sub new {
56
    my ( $class, $params ) = @_;
57
    my $self = bless $params // {}, $class;
58
59
    my $config = Koha::Config->get_instance;
60
61
    $self->{config} = $config;
62
    $self->{nonce}  = $params->{nonce};
63
    return $self;
64
}
65
66
=head2 header_name
67
68
    $csp->header_name($interface);
69
70
    Returns the name of the CSP header for given $interface.
71
72
    $interface can be one of: undef, 'opac', 'intranet'
73
    $interface defaults to C4::Context->interface.
74
75
    Returns 'Content-Security-Policy' if CSP is in "enabled" csp_mode
76
    Returns 'Content-Security-Policy-Report-Only' if CSP in "report-only" csp_mode
77
78
    Throws Koha::Exceptions::Config::MissingEntry is CSP csp_mode is disabled in koha-conf.xml
79
80
=cut
81
82
sub header_name {
83
    my ( $self, $interface ) = @_;
84
85
    $interface //= C4::Context->interface;
86
87
    my $conf_csp = $self->{config}->get('content_security_policy');
88
89
    Koha::Exceptions::Config::MissingEntry->throw( error => 'Missing request content_security_policy csp_mode' )
90
        unless exists $conf_csp->{$interface}->{csp_mode};
91
92
    return 'Content-Security-Policy-Report-Only' if $conf_csp->{$interface}->{csp_mode} eq 'report-only';
93
    return 'Content-Security-Policy'             if $conf_csp->{$interface}->{csp_mode} eq 'enabled';
94
95
    Koha::Exceptions::Config::MissingEntry->throw(
96
        error => 'Content Security Policy is disabled. Header name should only be retrieved when CSP is enabled.' );
97
}
98
99
=head2 header_value
100
101
    $csp->header_value($interface);
102
103
    Returns the value of the CSP header for given $interface.
104
105
    $interface can be one of: undef, 'opac', 'intranet'
106
    $interface defaults to C4::Context->interface.
107
108
    Returns content_security_policy.[opac|staff].csp_header_value, depending on $interface
109
110
    Throws Koha::Exceptions::Config::MissingEntry is CSP property "csp_header_value" is missing in koha-conf.xml
111
112
=cut
113
114
sub header_value {
115
    my ( $self, $interface ) = @_;
116
117
    $interface //= C4::Context->interface;
118
119
    my $conf_csp = $self->{config}->get('content_security_policy');
120
121
    Koha::Exceptions::Config::MissingEntry->throw( error => 'Missing request content_security_policy csp_header_value' )
122
        unless exists $conf_csp->{$interface}->{csp_header_value};
123
124
    my $csp_header_value = $conf_csp->{$interface}->{csp_header_value};
125
    my $cache            = Koha::Cache::Memory::Lite->get_instance();
126
    my $csp_nonce        = '';
127
    if ( defined $self->{nonce} ) {
128
        $csp_nonce = $self->{nonce};
129
    } elsif ( my $nonce = $cache->get_from_cache("csp_nonce") ) {
130
        $csp_nonce = $nonce if defined $nonce;
131
    }
132
    if ($csp_nonce) {
133
        $csp_header_value =~ s/_CSP_NONCE_/nonce-$csp_nonce/g;
134
    } elsif ( $csp_nonce eq '' ) {
135
        $csp_header_value =~ s/_CSP_NONCE_//g;
136
    }
137
138
    return $csp_header_value;
139
}
140
141
=head2 is_enabled
142
143
    $csp->is_enabled($interface);
144
145
    Checks if CSP is enabled for given $interface.
146
147
    $interface defaults to C4::Context->interface.
148
    $interface can be one of: 'opac', 'intranet'
149
150
    Returns 0 if CSP is disabled for this interface
151
    Returns 1 if CSP is enabled for this interface
152
153
=cut
154
155
sub is_enabled {
156
    my ( $self, $interface ) = @_;
157
158
    $interface //= C4::Context->interface;
159
160
    return 0 if $interface ne 'opac' && $interface ne 'intranet';
161
162
    my $conf_csp = $self->{config}->get('content_security_policy');
163
164
    return 0 unless $conf_csp;
165
    return 0 unless exists $conf_csp->{$interface};
166
    return 0 unless exists $conf_csp->{$interface}->{csp_mode};
167
    return 1
168
        if $conf_csp->{$interface}->{csp_mode} eq 'report-only'
169
        or $conf_csp->{$interface}->{csp_mode} eq 'enabled';
170
    return 0;
171
}
172
173
1;
(-)a/t/Koha/ContentSecurityPolicy.t (-1 / +138 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it
6
# under the terms of the GNU General Public License as published by
7
# the Free Software Foundation; either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# Koha is distributed in the hope that it will be useful, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with Koha; if not, see <https://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
20
use Test::NoWarnings;
21
use Test::More tests => 5;
22
use Test::Exception;
23
24
BEGIN { use_ok('Koha::ContentSecurityPolicy') }
25
26
use C4::Context;
27
use Koha::Cache::Memory::Lite;
28
29
use t::lib::Mocks;
30
31
my $conf_csp_section = 'content_security_policy';
32
33
subtest 'is_enabled() tests' => sub {
34
35
    plan tests => 4;
36
37
    foreach my $interface ( 'opac', 'intranet' ) {
38
        subtest "interface $interface" => sub {
39
            t::lib::Mocks::mock_config( $conf_csp_section, {} );
40
            C4::Context->interface($interface);
41
42
            my $csp = Koha::ContentSecurityPolicy->new;
43
44
            is( $csp->is_enabled, 0, 'CSP is not enabled when koha-conf.xml has not set the entire section' );
45
46
            t::lib::Mocks::mock_config(
47
                $conf_csp_section,
48
                { $interface => { csp_mode => '', csp_header_value => 'test' } }
49
            );
50
            is( $csp->is_enabled, 0, 'CSP is not enabled when csp_mode is empty' );
51
52
            t::lib::Mocks::mock_config(
53
                $conf_csp_section,
54
                { $interface => { csp_mode => 'wrong', csp_header_value => 'test' } }
55
            );
56
            is( $csp->is_enabled, 0, 'CSP is not enabled when csp_mode has not got a valid csp_header_value' );
57
58
            t::lib::Mocks::mock_config(
59
                $conf_csp_section,
60
                { $interface => { csp_mode => 'report-only', csp_header_value => 'test' } }
61
            );
62
            is( $csp->is_enabled, 1, 'CSP is enabled when csp_mode is report-only' );
63
64
            t::lib::Mocks::mock_config(
65
                $conf_csp_section,
66
                { $interface => { csp_mode => 'enabled', csp_header_value => 'test' } }
67
            );
68
            is( $csp->is_enabled, 1, 'CSP is enabled when csp_mode is enabled' );
69
        };
70
    }
71
72
    t::lib::Mocks::mock_config(
73
        $conf_csp_section, { opac => { csp_mode => 'enabled', csp_header_value => 'test' } },
74
        intranet => { csp_mode => '', csp_header_value => 'test' }
75
    );
76
    C4::Context->interface('intranet');
77
    my $csp = Koha::ContentSecurityPolicy->new;
78
    is( $csp->is_enabled,         0, 'CSP is disabled when other interface is enabled, but not this one' );
79
    is( $csp->is_enabled('opac'), 1, 'CSP is enabled when explicitly defining interface' );
80
};
81
82
subtest 'header_name tests' => sub {
83
    plan tests => 6;
84
85
    t::lib::Mocks::mock_config( $conf_csp_section, {} );
86
    C4::Context->interface('opac');
87
88
    my $csp = Koha::ContentSecurityPolicy->new;
89
90
    throws_ok { $csp->header_name } 'Koha::Exceptions::Config::MissingEntry', 'Exception thrown when missing csp_mode';
91
92
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => {} } );
93
    throws_ok { $csp->header_name } 'Koha::Exceptions::Config::MissingEntry', 'Exception thrown when missing csp_mode';
94
95
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_mode => '' } } );
96
    throws_ok { $csp->header_name } 'Koha::Exceptions::Config::MissingEntry', 'Exception thrown when missing csp_mode';
97
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_mode => 'invalid' } } );
98
    throws_ok { $csp->header_name } 'Koha::Exceptions::Config::MissingEntry', 'Exception thrown when invalid csp_mode';
99
100
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_mode => 'report-only' } } );
101
    is(
102
        $csp->header_name, 'Content-Security-Policy-Report-Only',
103
        'report-only => Content-Security-Policy-Report-Only'
104
    );
105
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_mode => 'enabled' } } );
106
    is( $csp->header_name, 'Content-Security-Policy', 'enabled => Content-Security-Policy' );
107
};
108
109
subtest 'header_value tests' => sub {
110
    plan tests => 6;
111
112
    t::lib::Mocks::mock_config( $conf_csp_section, {} );
113
    C4::Context->interface('opac');
114
115
    my $csp = Koha::ContentSecurityPolicy->new;
116
117
    throws_ok { $csp->header_value } 'Koha::Exceptions::Config::MissingEntry',
118
        'Exception thrown when missing csp_header_value';
119
120
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => {} } );
121
    throws_ok { $csp->header_value } 'Koha::Exceptions::Config::MissingEntry',
122
        'Exception thrown when missing csp_header_value';
123
124
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_header_value => '' } } );
125
    is( $csp->header_value, '', 'even an empty csp_header_value is retrieved form koha-conf.xml' );
126
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_header_value => 'some value' } } );
127
    is( $csp->header_value, 'some value', 'csp_header_value is retrieved from koha-conf.xml' );
128
129
    t::lib::Mocks::mock_config( $conf_csp_section, { opac => { csp_header_value => 'begin _CSP_NONCE_ end' } } );
130
    my $cache = Koha::Cache::Memory::Lite->get_instance();
131
    $cache->set_in_cache( 'csp_nonce', 'cached value' );
132
    is( $csp->header_value, 'begin nonce-cached value end', 'cached csp_header_value' );
133
134
    $csp = Koha::ContentSecurityPolicy->new( { nonce => 'forced value' } );
135
    is( $csp->header_value, 'begin nonce-forced value end', 'forced csp_header_value' );
136
};
137
138
1;

Return to bug 38365