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

(-)a/Koha/ContentSecurityPolicy.pm (+20 lines)
Lines 19-24 package Koha::ContentSecurityPolicy; Link Here
19
19
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use Digest::MD5;
23
use Encode;
24
use Mojo::JWT;
25
22
use C4::Context;
26
use C4::Context;
23
use Koha::Cache::Memory::Lite;
27
use Koha::Cache::Memory::Lite;
24
use Koha::Token;
28
use Koha::Token;
Lines 129-135 sub header_value { Link Here
129
    my $csp_header_value = $conf_csp->{$interface}->{csp_header_value};
133
    my $csp_header_value = $conf_csp->{$interface}->{csp_header_value};
130
    my $csp_nonce        = $self->get_nonce;
134
    my $csp_nonce        = $self->get_nonce;
131
135
136
    my $api_token = $self->api_token;
132
    $csp_header_value =~ s/_CSP_NONCE_/$csp_nonce/g;
137
    $csp_header_value =~ s/_CSP_NONCE_/$csp_nonce/g;
138
    $csp_header_value =~ s/_CSP_API_TOKEN_/$api_token/g;
133
139
134
    return $csp_header_value;
140
    return $csp_header_value;
135
}
141
}
Lines 214-217 sub set_nonce { Link Here
214
    return 1;
220
    return 1;
215
}
221
}
216
222
223
sub api_token {
224
    my $time = time();
225
    return Mojo::JWT->new(
226
        claims => {
227
            type      => 'csp-violation',
228
            timestamp => $time,
229
        },
230
        expires => $time + 15,                # the endpoint is valid for 15 seconds
231
        secret  => Digest::MD5::md5_base64(
232
            Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' )
233
        )
234
    )->encode;
235
}
236
217
1;
237
1;
(-)a/Koha/Middleware/ContentSecurityPolicy.pm (-5 / +6 lines)
Lines 54-60 sub call { Link Here
54
54
55
                # if reporting-endpoints already exists, append it
55
                # if reporting-endpoints already exists, append it
56
                if ( lc( $headers->[$i] ) eq 'reporting-endpoints' ) {
56
                if ( lc( $headers->[$i] ) eq 'reporting-endpoints' ) {
57
                    $headers->[ $i + 1 ] = _add_csp_to_reporting_endpoints( $headers->[ $i + 1 ] );
57
                    $headers->[ $i + 1 ] = _add_csp_to_reporting_endpoints( $csp, $headers->[ $i + 1 ] );
58
                    $add_reporting_endpoints = 0;
58
                    $add_reporting_endpoints = 0;
59
                    last;
59
                    last;
60
                }
60
                }
Lines 62-68 sub call { Link Here
62
62
63
            # reporting-endpoints is not yet defined, so let's define it
63
            # reporting-endpoints is not yet defined, so let's define it
64
            if ($add_reporting_endpoints) {
64
            if ($add_reporting_endpoints) {
65
                push @$headers, ( 'Reporting-Endpoints' => _add_csp_to_reporting_endpoints() );
65
                push @$headers, ( 'Reporting-Endpoints' => _add_csp_to_reporting_endpoints($csp) );
66
            }
66
            }
67
            push @$headers, ( $csp->header_name => $csp->header_value );
67
            push @$headers, ( $csp->header_name => $csp->header_value );
68
        }
68
        }
Lines 70-83 sub call { Link Here
70
}
70
}
71
71
72
sub _add_csp_to_reporting_endpoints {
72
sub _add_csp_to_reporting_endpoints {
73
    my ($value) = @_;
73
    my ( $csp, $value ) = @_;
74
    if ( $value && $value =~ /^\w+/ ) {
74
    if ( $value && $value =~ /^\w+/ ) {
75
        $value = $value . ', ';
75
        $value = $value . ', ';
76
    } else {
76
    } else {
77
        $value = '';
77
        $value = '';
78
    }
78
    }
79
    $value = $value . 'csp-violations="/api/v1/public/csp-reports"';
80
79
81
    return $value;
80
    my $token = $csp->api_token;
81
    return $value . "csp-violations=\"/api/v1/public/csp-reports?token=$token\"";
82
}
82
}
83
83
1;
84
1;
(-)a/Koha/REST/V1/CSPReports.pm (+34 lines)
Lines 20-28 package Koha::REST::V1::CSPReports; Link Here
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use Mojo::Base 'Mojolicious::Controller';
22
use Mojo::Base 'Mojolicious::Controller';
23
use Mojo::JWT;
23
24
24
use Koha::Logger;
25
use Koha::Logger;
25
26
27
use Try::Tiny qw( try catch );
28
26
=head1 NAME
29
=head1 NAME
27
30
28
Koha::REST::V1::CSPReports - Controller for Content-Security-Policy violation reports
31
Koha::REST::V1::CSPReports - Controller for Content-Security-Policy violation reports
Lines 46-51 is violated. This endpoint logs those reports for administrator review. Link Here
46
sub add {
49
sub add {
47
    my $c = shift->openapi->valid_input or return;
50
    my $c = shift->openapi->valid_input or return;
48
51
52
    my $token = $c->param('token');
53
    my $decoded_jwt;
54
    my $expired = 0;
55
    try {
56
        $decoded_jwt = Mojo::JWT->new(
57
            secret => Digest::MD5::md5_base64(
58
                Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' )
59
            )
60
        )->decode($token);
61
    } catch {
62
        if ( $_ && ref($_) eq 'Mojo::Exception' ) {
63
            $expired = 1 if $_->message =~ /^JWT has expired/;
64
        }
65
    };
66
    return $c->render(
67
        status  => 400,
68
        openapi => { error => 'This token has expired.' }
69
    ) if $expired;
70
71
    return $c->render(
72
        status  => 401,
73
        openapi => { error => 'Invalid token.' }
74
    ) unless $decoded_jwt;
75
76
    if ( !$decoded_jwt->{type} || $decoded_jwt->{type} ne 'csp-violation' ) {
77
        return $c->render(
78
            status  => 400,
79
            openapi => { error => 'Invalid token type.' }
80
        );
81
    }
82
49
    my $report = $c->req->json;
83
    my $report = $c->req->json;
50
84
51
    # CSP reports come wrapped in a 'csp-report' key
85
    # CSP reports come wrapped in a 'csp-report' key
(-)a/api/v1/swagger/paths/public_csp_reports.yaml (+10 lines)
Lines 26-31 Link Here
26
        required: true
26
        required: true
27
        schema:
27
        schema:
28
          $ref: "../swagger.yaml#/definitions/csp_report"
28
          $ref: "../swagger.yaml#/definitions/csp_report"
29
      - name: token
30
        in: query
31
        description: Temporary token found in Reporting-Endpoints response header
32
        required: true
33
        type: string
34
        minLength: 4
29
    responses:
35
    responses:
30
      "204":
36
      "204":
31
        description: Report received successfully
37
        description: Report received successfully
Lines 33-38 Link Here
33
        description: Bad request
39
        description: Bad request
34
        schema:
40
        schema:
35
          $ref: "../swagger.yaml#/definitions/error"
41
          $ref: "../swagger.yaml#/definitions/error"
42
      "401":
43
        description: Invalid token
44
        schema:
45
          $ref: "../swagger.yaml#/definitions/error"
36
      "500":
46
      "500":
37
        description: Internal server error
47
        description: Internal server error
38
        schema:
48
        schema:
(-)a/debian/templates/koha-conf-site.xml.in (-1 / +2 lines)
Lines 513-518 __END_SRU_PUBLICSERVER__ Link Here
513
513
514
   csp_header_value: The CSP policy directives. Special placeholders:
514
   csp_header_value: The CSP policy directives. Special placeholders:
515
     - _CSP_NONCE_: Replaced with a unique nonce for each request
515
     - _CSP_NONCE_: Replaced with a unique nonce for each request
516
     - _CSP_API_TOKEN_: Replaced with a token for verifying the CSP violation report payload
516
517
517
   Recommended workflow:
518
   Recommended workflow:
518
     1. Start with report-only mode to identify violations
519
     1. Start with report-only mode to identify violations
Lines 521-527 __END_SRU_PUBLICSERVER__ Link Here
521
522
522
   To enable violation reporting, add report-to to your policy pointing to csp-violations.
523
   To enable violation reporting, add report-to to your policy pointing to csp-violations.
523
   Example with reporting:
524
   Example with reporting:
524
     <csp_header_value>default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; report-uri /api/v1/public/csp-reports; report-to csp-violations</csp_header_value>
525
     <csp_header_value>default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; report-uri /api/v1/public/csp-reports?token=_CSP_API_TOKEN_; report-to csp-violations</csp_header_value>
525
526
526
   Reports are logged via Koha's logging system. Configure log4perl to capture them:
527
   Reports are logged via Koha's logging system. Configure log4perl to capture them:
527
     log4perl.logger.api.csp = WARN, CSP
528
     log4perl.logger.api.csp = WARN, CSP
(-)a/etc/koha-conf.xml (-1 / +2 lines)
Lines 324-329 Link Here
324
324
325
   csp_header_value: The CSP policy directives. Special placeholders:
325
   csp_header_value: The CSP policy directives. Special placeholders:
326
     - _CSP_NONCE_: Replaced with a unique nonce for each request
326
     - _CSP_NONCE_: Replaced with a unique nonce for each request
327
     - _CSP_API_TOKEN_: Replaced with a token for verifying the CSP violation report payload
327
328
328
   Recommended workflow:
329
   Recommended workflow:
329
     1. Start with report-only mode to identify violations
330
     1. Start with report-only mode to identify violations
Lines 332-338 Link Here
332
333
333
   To enable violation reporting, add report-to to your policy pointing csp-violations.
334
   To enable violation reporting, add report-to to your policy pointing csp-violations.
334
   Example with reporting:
335
   Example with reporting:
335
     <csp_header_value>default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; report-uri /api/v1/public/csp-reports; report-to csp-violations</csp_header_value>
336
     <csp_header_value>default-src 'self'; script-src 'self' 'nonce-_CSP_NONCE_'; style-src 'self' 'nonce-_CSP_NONCE_'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; report-uri /api/v1/public/csp-reports?token=_CSP_API_TOKEN_; report-to csp-violations</csp_header_value>
336
337
337
   Reports are logged via Koha's logging system. Configure log4perl to capture them:
338
   Reports are logged via Koha's logging system. Configure log4perl to capture them:
338
     log4perl.logger.api.csp = WARN, CSP
339
     log4perl.logger.api.csp = WARN, CSP
(-)a/t/Koha/ContentSecurityPolicy.t (-1 / +16 lines)
Lines 19-25 use Modern::Perl; Link Here
19
19
20
use Test::NoWarnings;
20
use Test::NoWarnings;
21
use Test::MockModule;
21
use Test::MockModule;
22
use Test::More tests => 6;
22
use Test::More tests => 7;
23
use Test::Exception;
23
use Test::Exception;
24
24
25
BEGIN { use_ok('Koha::ContentSecurityPolicy') }
25
BEGIN { use_ok('Koha::ContentSecurityPolicy') }
Lines 172-175 subtest 'nonce tests' => sub { Link Here
172
    is( $csp->get_nonce, 'cached value', 'nonce is not re-generated as it was previously cached' );
172
    is( $csp->get_nonce, 'cached value', 'nonce is not re-generated as it was previously cached' );
173
};
173
};
174
174
175
subtest 'api_token() tests' => sub {
176
    t::lib::Mocks::mock_config(
177
        $conf_csp_section,
178
        { opac => { csp_header_value => 'begin |_CSP_NONCE_| |_CSP_API_TOKEN_| end' } }
179
    );
180
181
    my $csp = Koha::ContentSecurityPolicy->new;
182
183
    $csp->set_nonce('cached value');
184
    like(
185
        $csp->header_value, qr/^begin |cached value| |.{22,}| end$/,
186
        '_CSP_API_TOKEN_ got replaced, together with the nonce'
187
    );
188
};
189
175
1;
190
1;
(-)a/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t (-7 / +20 lines)
Lines 165-175 subtest 'test CSP in staff client' => sub { Link Here
165
};
165
};
166
166
167
subtest 'test Reporting-Endpoints for CSP violation reports' => sub {
167
subtest 'test Reporting-Endpoints for CSP violation reports' => sub {
168
    plan tests => 1;
168
    plan tests => 5;
169
169
170
    my $test_nonce = 'TEST_NONCE';
170
    my $test_nonce = 'TEST_NONCE';
171
    my $csp_header_value =
171
    my $csp_header_value =
172
        "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'";
172
        "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'; report-uri /api/v1/public/csp-reports?token=_CSP_API_TOKEN_";
173
173
174
    t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
174
    t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
175
175
Lines 195-208 subtest 'test Reporting-Endpoints for CSP violation reports' => sub { Link Here
195
        };
195
        };
196
    };
196
    };
197
197
198
    my $test                      = Plack::Test->create($app);
198
    my $test = Plack::Test->create($app);
199
    my $res                       = $test->request( GET "/opac/opac-main.pl" );
199
    my $res  = $test->request( GET "/opac/opac-main.pl" );
200
    my $expected_csp_header_value = $csp_header_value;
201
    $expected_csp_header_value =~ s/_CSP_NONCE_/$test_nonce/g;
202
    like(
200
    like(
203
        $res->header('reporting-endpoints'), qr/^csp-violations="\/api\/v1\/public\/csp-reports"$/,
201
        $res->header('content-security-policy'), qr/report-uri \/api\/v1\/public\/csp-reports\?token=[\w\.\-]{22,}$/,
202
        "Response contains Content-Security-Policy header when it is enabled in koha-conf.xml"
203
    );
204
    like(
205
        $res->header('reporting-endpoints'), qr/^csp-violations="\/api\/v1\/public\/csp-reports\?token=[\w\.\-]+"$/,
204
        "Response contains Reporting-Endpoints header with the expected value"
206
        "Response contains Reporting-Endpoints header with the expected value"
205
    );
207
    );
208
    my ($token) = $res->header('reporting-endpoints') =~ /token=([\w\.\-]+)"$/;
209
210
    my $decoded_jwt = Mojo::JWT->new(
211
        secret => Digest::MD5::md5_base64(
212
            Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' )
213
        )
214
    )->decode($token);
215
216
    cmp_ok( $decoded_jwt->{timestamp}, '<=', time(), 'JWT timestamp is less or equal to current timestamp' );
217
    cmp_ok( $decoded_jwt->{exp},       '>=', time(), 'JWT expires in the future' );
218
    is( $decoded_jwt->{type}, 'csp-violation', 'JWT type is csp-violation' );
206
219
207
    $schema->storage->txn_rollback;
220
    $schema->storage->txn_rollback;
208
};
221
};
(-)a/t/db_dependent/api/v1/public/csp_reports.t (-11 / +43 lines)
Lines 28-42 use Log::Log4perl; Link Here
28
use t::lib::Mocks;
28
use t::lib::Mocks;
29
29
30
use Koha::Database;
30
use Koha::Database;
31
use Koha::ContentSecurityPolicy;
31
32
32
my $schema = Koha::Database->new->schema;
33
my $schema = Koha::Database->new->schema;
33
my $t      = Test::Mojo->new('Koha::REST::V1');
34
my $t      = Test::Mojo->new('Koha::REST::V1');
34
35
35
t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
36
t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
37
t::lib::Mocks::mock_config( 'api_secret_passphrase', 'test' );
38
39
my $csp = Koha::ContentSecurityPolicy->new;
36
40
37
subtest 'add() tests' => sub {
41
subtest 'add() tests' => sub {
38
42
39
    plan tests => 7;
43
    plan tests => 16;
40
44
41
    $schema->storage->txn_begin;
45
    $schema->storage->txn_begin;
42
46
Lines 57-68 subtest 'add() tests' => sub { Link Here
57
        }
61
        }
58
    };
62
    };
59
63
60
    # Anonymous request should work (browsers send these without auth)
64
    # Anonymous request requires token
61
    $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
65
    $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
66
        ->status_is( 400, 'CSP report denied (400)' )
67
        ->content_like( qr/Missing property.*\/token"/, '...because of a missing token' );
68
69
    # Anonymous request requires valid token
70
    $t->post_ok( '/api/v1/public/csp-reports?token=invalid' => { 'Content-Type' => 'application/csp-report' } => json =>
71
            $csp_report )->status_is( 401, 'CSP report denied (401)' )
72
        ->json_is( '/error', 'Invalid token.', '...because of an invalid token' );
73
74
    # Anonymous request requires a non-expired token
75
    my $expired_token = Mojo::JWT->new(
76
        claims => {
77
            type      => 'csp-violation',
78
            timestamp => 0,
79
        },
80
        expires => 0,
81
        secret  => Digest::MD5::md5_base64(
82
            Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' )
83
        )
84
    )->encode;
85
86
    $t->post_ok( '/api/v1/public/csp-reports?token='
87
            . $expired_token => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
88
        ->status_is( 400, 'CSP report denied (400)' )
89
        ->json_is( '/error', 'This token has expired.', '...because of an expired token' );
90
91
    # Anonymous request should work (browsers send these without auth)
92
    $t->post_ok( '/api/v1/public/csp-reports?token='
93
            . $csp->api_token => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
62
        ->status_is( 204, 'CSP report accepted' );
94
        ->status_is( 204, 'CSP report accepted' );
63
95
64
    # Test with application/json content type (also valid)
96
    # Test with application/json content type (also valid)
65
    $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/json' } => json => $csp_report )
97
    $t->post_ok( '/api/v1/public/csp-reports?token='
98
            . $csp->api_token => { 'Content-Type' => 'application/json' } => json => $csp_report )
66
        ->status_is( 204, 'CSP report accepted with application/json content type' );
99
        ->status_is( 204, 'CSP report accepted with application/json content type' );
67
100
68
    # Test with minimal report
101
    # Test with minimal report
Lines 74-80 subtest 'add() tests' => sub { Link Here
74
        }
107
        }
75
    };
108
    };
76
109
77
    $t->post_ok( '/api/v1/public/csp-reports' => json => $minimal_report )
110
    $t->post_ok( '/api/v1/public/csp-reports?token=' . $csp->api_token => json => $minimal_report )
78
        ->status_is( 204, 'Minimal CSP report accepted' );
111
        ->status_is( 204, 'Minimal CSP report accepted' );
79
112
80
    subtest 'make sure log entries are being written' => sub {
113
    subtest 'make sure log entries are being written' => sub {
Lines 114-121 subtest 'add() tests' => sub { Link Here
114
        is( $appender->buffer,      '', 'Nothing in log buffer yet' );
147
        is( $appender->buffer,      '', 'Nothing in log buffer yet' );
115
        is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' );
148
        is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' );
116
149
117
        $t->post_ok(
150
        $t->post_ok( '/api/v1/public/csp-reports?token='
118
            '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
151
                . $csp->api_token => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
119
            ->status_is( 204, 'CSP report accepted' );
152
            ->status_is( 204, 'CSP report accepted' );
120
153
121
        my $expected_log_entry = sprintf(
154
        my $expected_log_entry = sprintf(
Lines 137-144 subtest 'add() tests' => sub { Link Here
137
        $appender->clear();
170
        $appender->clear();
138
171
139
        $ENV{'plack.is.enabled.for.this.test'} = 1;    # tricking C4::Context->psgi_env
172
        $ENV{'plack.is.enabled.for.this.test'} = 1;    # tricking C4::Context->psgi_env
140
        $t->post_ok(
173
        $t->post_ok( '/api/v1/public/csp-reports?token='
141
            '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
174
                . $csp->api_token => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
142
            ->status_is( 204, 'CSP report accepted' );
175
            ->status_is( 204, 'CSP report accepted' );
143
        like(
176
        like(
144
            $appenderplack->buffer, qr/$expected_log_entry/
177
            $appenderplack->buffer, qr/$expected_log_entry/
Lines 157-164 log4perl.appender.API.utf8=1 Link Here
157
HERE
190
HERE
158
        );
191
        );
159
        $appender = Log::Log4perl->appenders()->{API};
192
        $appender = Log::Log4perl->appenders()->{API};
160
        $t->post_ok(
193
        $t->post_ok( '/api/v1/public/csp-reports?token='
161
            '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
194
                . $csp->api_token => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
162
            ->status_is( 204, 'CSP report returns 204 even when no CSP loggers are defined' );
195
            ->status_is( 204, 'CSP report returns 204 even when no CSP loggers are defined' );
163
        is( $appender->buffer, '', 'Nothing in the only defined log buffer, because it is unrelated to CSP' );
196
        is( $appender->buffer, '', 'Nothing in the only defined log buffer, because it is unrelated to CSP' );
164
197
165
- 

Return to bug 38365