Bug 36598: Enable CSRF protection for Mojolicious apps

Test plan:
1. Run bin/opac daemon -l http://*:3001/
2. Go to http://localhost:3001/cgi-bin/koha/opac-user.pl
3. With browser devtools, locate csrf_token hidden input within the
   login form and remove it or modify it
4. Try to submit the form with correct credentials, it should fail
   ("Wrong CSRF token")
5. Reload the page, try to log in normally without modifying the DOM, it
   should succeed
6. Run bin/intranet daemon -l http://*:3002/
7. Go to http://localhost:3002/cgi-bin/koha/mainpage.pl
8. With browser devtools, locate csrf_token hidden input within the
   login form and remove it or modify it
9. Try to submit the form with correct credentials, it should fail
   ("Wrong CSRF token")
10. Reload the page, try to log in normally without modifying the DOM,
    it should succeed
11. Run prove t/db_dependent/mojo/csrf.t

Signed-off-by: Matt Blenkinsop <matt.blenkinsop@ptfs-europe.com>
Signed-off-by: Victor Grousset/tuxayo <victor@tuxayo.net>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Julian Maurice 2024-04-12 15:08:17 +02:00 committed by Lucas Gass
parent ae84680c4d
commit 84054b932c
4 changed files with 180 additions and 0 deletions

View File

@ -38,6 +38,8 @@ sub startup {
# FIXME This generates routes like this: /api/api/v1/...
$self->plugin('RESTV1');
$self->plugin('CSRF');
$self->hook(before_dispatch => \&_before_dispatch);
$self->hook(around_action => \&_around_action);

View File

@ -38,6 +38,8 @@ sub startup {
# FIXME This generates routes like this: /api/api/v1/...
$self->plugin('RESTV1');
$self->plugin('CSRF');
$self->hook(before_dispatch => \&_before_dispatch);
$self->hook(around_action => \&_around_action);

100
Koha/App/Plugin/CSRF.pm Normal file
View File

@ -0,0 +1,100 @@
package Koha::App::Plugin::CSRF;
# 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 <http://www.gnu.org/licenses>.
=head1 NAME
Koha::App::Plugin::CSRF
=head1 SYNOPSIS
$app->plugin('CSRF');
=head1 DESCRIPTION
Enables CSRF protection in a Mojolicious app
=cut
use Modern::Perl;
use Mojo::Base 'Mojolicious::Plugin';
use Mojo::Message::Response;
use Koha::Token;
=head1 METHODS
=head2 register
Called by Mojolicious when the plugin is loaded.
Defines an `around_action` hook that will return a 403 response if CSRF token
is missing or invalid.
This verification occurs only for HTTP methods POST, PUT, DELETE and PATCH.
If CGISESSID cookie is missing, it means that we are not authenticated or we
are authenticated to the API by another method (HTTP basic or OAuth2). In this
case, no verification is done.
=cut
sub register {
my ( $self, $app, $conf ) = @_;
$app->hook(
around_action => sub {
my ( $next, $c, $action, $last ) = @_;
my $method = $c->req->method;
if ( $method eq 'POST' || $method eq 'PUT' || $method eq 'DELETE' || $method eq 'PATCH' ) {
if ( $c->cookie('CGISESSID') && !$self->is_csrf_valid( $c->req ) ) {
return $c->reply->exception('Wrong CSRF token')->rendered(403);
}
}
return $next->();
}
);
}
=head2 is_csrf_valid
Checks if a CSRF token exists and is valid
$is_valid = $plugin->is_csrf_valid($req)
C<$req> must be a Mojo::Message::Request object
=cut
sub is_csrf_valid {
my ( $self, $req ) = @_;
my $csrf_token = $req->param('csrf_token') || $req->headers->header('CSRF_TOKEN');
my $cookie = $req->cookie('CGISESSID');
if ( $csrf_token && $cookie ) {
my $session_id = $cookie->value;
return Koha::Token->new->check_csrf( { session_id => $session_id, token => $csrf_token } );
}
return 0;
}
1;

76
t/db_dependent/mojo/csrf.t Executable file
View File

@ -0,0 +1,76 @@
#!/usr/bin/perl
use Modern::Perl;
use Test::More tests => 2;
use Test::Mojo;
use Koha::Database;
subtest 'CSRF - Intranet' => sub {
plan tests => 4;
my $t = Test::Mojo->new('Koha::App::Intranet');
# Make sure we have a CGISESSID cookie
$t->get_ok('/cgi-bin/koha/mainpage.pl');
subtest 'Without a CSRF token' => sub {
plan tests => 3;
$t->post_ok('/cgi-bin/koha/mainpage.pl')->status_is(403)
->content_like( qr/Wrong CSRF token/, 'Body contains "Wrong CSRF token"' );
};
subtest 'With a wrong CSRF token' => sub {
plan tests => 3;
$t->post_ok( '/cgi-bin/koha/mainpage.pl', form => { csrf_token => 'BAD', op => 'cud-login' } )->status_is(403)
->content_like( qr/Wrong CSRF token/, 'Body contains "Wrong CSRF token"' );
};
subtest 'With a good CSRF token' => sub {
plan tests => 4;
$t->get_ok('/cgi-bin/koha/mainpage.pl');
my $csrf_token = $t->tx->res->dom('input[name="csrf_token"]')->map( attr => 'value' )->first;
$t->post_ok( '/cgi-bin/koha/mainpage.pl', form => { csrf_token => $csrf_token, op => 'cud-login' } )
->status_is(200)->content_like( qr/Please log in again/, 'Login failed but CSRF test passed' );
};
};
subtest 'CSRF - OPAC' => sub {
plan tests => 4;
my $t = Test::Mojo->new('Koha::App::Opac');
# Make sure we have a CGISESSID cookie
$t->get_ok('/cgi-bin/koha/opac-user.pl');
subtest 'Without a CSRF token' => sub {
plan tests => 3;
$t->post_ok('/cgi-bin/koha/opac-user.pl')->status_is(403)
->content_like( qr/Wrong CSRF token/, 'Body contains "Wrong CSRF token"' );
};
subtest 'With a wrong CSRF token' => sub {
plan tests => 3;
$t->post_ok( '/cgi-bin/koha/opac-user.pl', form => { csrf_token => 'BAD', op => 'cud-login' } )->status_is(403)
->content_like( qr/Wrong CSRF token/, 'Body contains "Wrong CSRF token"' );
};
subtest 'With a good CSRF token' => sub {
plan tests => 4;
$t->get_ok('/cgi-bin/koha/opac-user.pl');
my $csrf_token = $t->tx->res->dom('input[name="csrf_token"]')->map( attr => 'value' )->first;
$t->post_ok( '/cgi-bin/koha/opac-user.pl', form => { csrf_token => $csrf_token, op => 'cud-login' } )
->status_is(200)->content_like( qr/Log in to your account/, 'Login failed but CSRF test passed' );
};
};