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

(-)a/Koha/Middleware/ContentSecurityPolicy.pm (-1 / +16 lines)
Lines 18-23 package Koha::Middleware::ContentSecurityPolicy; Link Here
18
use Modern::Perl;
18
use Modern::Perl;
19
19
20
use parent qw(Plack::Middleware);
20
use parent qw(Plack::Middleware);
21
use Encode;
22
use Digest::MD5;
23
use Mojo::JWT;
21
use Plack::Request;
24
use Plack::Request;
22
use Plack::Response;
25
use Plack::Response;
23
26
Lines 76-82 sub _add_csp_to_reporting_endpoints { Link Here
76
    } else {
79
    } else {
77
        $value = '';
80
        $value = '';
78
    }
81
    }
79
    $value = $value . 'csp-violations="/api/v1/public/csp-reports"';
82
83
    my $time  = time();
84
    my $token = Mojo::JWT->new(
85
        claims => {
86
            type      => 'csp-violation',
87
            timestamp => $time,
88
        },
89
        expires => $time + 15,                # the endpoint is valid for 15 seconds
90
        secret  => Digest::MD5::md5_base64(
91
            Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' )
92
        )
93
    )->encode;
94
    $value = $value . 'csp-violations="/api/v1/public/csp/report-uri?token=' . $token . '"';
80
95
81
    return $value;
96
    return $value;
82
}
97
}
(-)a/Koha/REST/V1/CSPReports.pm (+32 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
    try {
55
        $decoded_jwt = Mojo::JWT->new(
56
            secret => Digest::MD5::md5_base64(
57
                Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' )
58
            )
59
        )->decode($token);
60
    } catch {
61
    };
62
63
    return $c->render(
64
        status  => 401,
65
        openapi => { error => 'Invalid token.' }
66
    ) unless $decoded_jwt;
67
68
    if ( !$decoded_jwt->{timestamp} || $decoded_jwt->{timestamp} + 15 < time() ) {
69
        return $c->render(
70
            status  => 400,
71
            openapi => { error => 'This token has expired.' }
72
        );
73
    }
74
    if ( !$decoded_jwt->{type} || $decoded_jwt->{type} ne 'csp-violation' ) {
75
        return $c->render(
76
            status  => 400,
77
            openapi => { error => 'Invalid token type.' }
78
        );
79
    }
80
49
    my $report = $c->req->json;
81
    my $report = $c->req->json;
50
82
51
    # CSP reports come wrapped in a 'csp-report' key
83
    # 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/t/db_dependent/Koha/Middleware/ContentSecurityPolicy.t (-2 / +13 lines)
Lines 165-171 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 => 4;
169
169
170
    my $test_nonce = 'TEST_NONCE';
170
    my $test_nonce = 'TEST_NONCE';
171
    my $csp_header_value =
171
    my $csp_header_value =
Lines 200-208 subtest 'test Reporting-Endpoints for CSP violation reports' => sub { Link Here
200
    my $expected_csp_header_value = $csp_header_value;
200
    my $expected_csp_header_value = $csp_header_value;
201
    $expected_csp_header_value =~ s/_CSP_NONCE_/$test_nonce/g;
201
    $expected_csp_header_value =~ s/_CSP_NONCE_/$test_nonce/g;
202
    like(
202
    like(
203
        $res->header('reporting-endpoints'), qr/^csp-violations="\/api\/v1\/public\/csp-reports"$/,
203
        $res->header('reporting-endpoints'), qr/^csp-violations="\/api\/v1\/public\/csp\/report-uri\?token=[\w\.\-]+"$/,
204
        "Response contains Reporting-Endpoints header with the expected value"
204
        "Response contains Reporting-Endpoints header with the expected value"
205
    );
205
    );
206
    my ($token) = $res->header('reporting-endpoints') =~ /token=([\w\.\-]+)"$/;
207
208
    my $decoded_jwt = Mojo::JWT->new(
209
        secret => Digest::MD5::md5_base64(
210
            Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' )
211
        )
212
    )->decode($token);
213
214
    cmp_ok( $decoded_jwt->{timestamp}, '<=', time(), 'JWT timestamp is less or equal to current timestamp' );
215
    cmp_ok( $decoded_jwt->{exp},       '>=', time(), 'JWT expires in the future' );
216
    is( $decoded_jwt->{type}, 'csp-violation', 'JWT type is csp-violation' );
206
217
207
    $schema->storage->txn_rollback;
218
    $schema->storage->txn_rollback;
208
};
219
};
(-)a/t/db_dependent/api/v1/public/csp_reports.t (-11 / +37 lines)
Lines 33-42 my $schema = Koha::Database->new->schema; Link Here
33
my $t      = Test::Mojo->new('Koha::REST::V1');
33
my $t      = Test::Mojo->new('Koha::REST::V1');
34
34
35
t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
35
t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
36
t::lib::Mocks::mock_config( 'api_secret_passphrase', 'test' );
36
37
37
subtest 'add() tests' => sub {
38
subtest 'add() tests' => sub {
38
39
39
    plan tests => 7;
40
    plan tests => 13;
40
41
41
    $schema->storage->txn_begin;
42
    $schema->storage->txn_begin;
42
43
Lines 57-68 subtest 'add() tests' => sub { Link Here
57
        }
58
        }
58
    };
59
    };
59
60
60
    # Anonymous request should work (browsers send these without auth)
61
    # Anonymous request requires token
61
    $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
62
    $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
63
        ->status_is( 400, 'CSP report denied (400)' )
64
        ->content_like( qr/Missing property.*\/token"/, '...because of a missing token' );
65
66
    # Anonymous request requires valid token
67
    $t->post_ok( '/api/v1/public/csp-reports?token=invalid' => { 'Content-Type' => 'application/csp-report' } => json =>
68
            $csp_report )->status_is( 401, 'CSP report denied (401)' )
69
        ->json_is( '/error', 'Invalid token.', '...because of an invalid token' );
70
71
    # Anonymous request should work (browsers send these without auth)
72
    $t->post_ok( '/api/v1/public/csp-reports?token='
73
            . csp_token() => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
62
        ->status_is( 204, 'CSP report accepted' );
74
        ->status_is( 204, 'CSP report accepted' );
63
75
64
    # Test with application/json content type (also valid)
76
    # Test with application/json content type (also valid)
65
    $t->post_ok( '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/json' } => json => $csp_report )
77
    $t->post_ok( '/api/v1/public/csp-reports?token='
78
            . csp_token() => { 'Content-Type' => 'application/json' } => json => $csp_report )
66
        ->status_is( 204, 'CSP report accepted with application/json content type' );
79
        ->status_is( 204, 'CSP report accepted with application/json content type' );
67
80
68
    # Test with minimal report
81
    # Test with minimal report
Lines 74-80 subtest 'add() tests' => sub { Link Here
74
        }
87
        }
75
    };
88
    };
76
89
77
    $t->post_ok( '/api/v1/public/csp-reports' => json => $minimal_report )
90
    $t->post_ok( '/api/v1/public/csp-reports?token=' . csp_token() => json => $minimal_report )
78
        ->status_is( 204, 'Minimal CSP report accepted' );
91
        ->status_is( 204, 'Minimal CSP report accepted' );
79
92
80
    subtest 'make sure log entries are being written' => sub {
93
    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' );
127
        is( $appender->buffer,      '', 'Nothing in log buffer yet' );
115
        is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' );
128
        is( $appenderplack->buffer, '', 'Nothing in plack log buffer yet' );
116
129
117
        $t->post_ok(
130
        $t->post_ok( '/api/v1/public/csp-reports?token='
118
            '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
131
                . csp_token() => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
119
            ->status_is( 204, 'CSP report accepted' );
132
            ->status_is( 204, 'CSP report accepted' );
120
133
121
        my $expected_log_entry = sprintf(
134
        my $expected_log_entry = sprintf(
Lines 137-144 subtest 'add() tests' => sub { Link Here
137
        $appender->clear();
150
        $appender->clear();
138
151
139
        $ENV{'plack.is.enabled.for.this.test'} = 1;    # tricking C4::Context->psgi_env
152
        $ENV{'plack.is.enabled.for.this.test'} = 1;    # tricking C4::Context->psgi_env
140
        $t->post_ok(
153
        $t->post_ok( '/api/v1/public/csp-reports?token='
141
            '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
154
                . csp_token() => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
142
            ->status_is( 204, 'CSP report accepted' );
155
            ->status_is( 204, 'CSP report accepted' );
143
        like(
156
        like(
144
            $appenderplack->buffer, qr/$expected_log_entry/
157
            $appenderplack->buffer, qr/$expected_log_entry/
Lines 157-164 log4perl.appender.API.utf8=1 Link Here
157
HERE
170
HERE
158
        );
171
        );
159
        $appender = Log::Log4perl->appenders()->{API};
172
        $appender = Log::Log4perl->appenders()->{API};
160
        $t->post_ok(
173
        $t->post_ok( '/api/v1/public/csp-reports?token='
161
            '/api/v1/public/csp-reports' => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
174
                . csp_token() => { 'Content-Type' => 'application/csp-report' } => json => $csp_report )
162
            ->status_is( 204, 'CSP report returns 204 even when no CSP loggers are defined' );
175
            ->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' );
176
        is( $appender->buffer, '', 'Nothing in the only defined log buffer, because it is unrelated to CSP' );
164
177
Lines 167-170 HERE Link Here
167
    $schema->storage->txn_rollback;
180
    $schema->storage->txn_rollback;
168
};
181
};
169
182
183
sub csp_token {
184
    my $time = time();
185
    return Mojo::JWT->new(
186
        claims => {
187
            type      => 'csp-violation',
188
            timestamp => $time,
189
        },
190
        expires => $time + 15,                # the endpoint is valid for 15 seconds
191
        secret  => Digest::MD5::md5_base64(
192
            Encode::encode( 'UTF-8', C4::Context->config('api_secret_passphrase') || 'unsafe' )
193
        )
194
    )->encode;
195
}
196
170
1;
197
1;
171
- 

Return to bug 38365