From e5c1bcd122d1781572e18d1e4e701bb76a694c87 Mon Sep 17 00:00:00 2001 From: Julian Maurice Date: Fri, 12 Apr 2024 15:08:17 +0200 Subject: [PATCH] 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 --- Koha/App/Intranet.pm | 2 + Koha/App/Opac.pm | 2 + Koha/App/Plugin/CSRF.pm | 100 +++++++++++++++++++++++++++++++++++++ t/db_dependent/mojo/csrf.t | 76 ++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 Koha/App/Plugin/CSRF.pm create mode 100755 t/db_dependent/mojo/csrf.t diff --git a/Koha/App/Intranet.pm b/Koha/App/Intranet.pm index bf66e9e62b..558de8da1c 100644 --- a/Koha/App/Intranet.pm +++ b/Koha/App/Intranet.pm @@ -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); diff --git a/Koha/App/Opac.pm b/Koha/App/Opac.pm index 7af85e1515..1f722360e4 100644 --- a/Koha/App/Opac.pm +++ b/Koha/App/Opac.pm @@ -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); diff --git a/Koha/App/Plugin/CSRF.pm b/Koha/App/Plugin/CSRF.pm new file mode 100644 index 0000000000..0d2846ae53 --- /dev/null +++ b/Koha/App/Plugin/CSRF.pm @@ -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 . + +=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; diff --git a/t/db_dependent/mojo/csrf.t b/t/db_dependent/mojo/csrf.t new file mode 100755 index 0000000000..02b707301d --- /dev/null +++ b/t/db_dependent/mojo/csrf.t @@ -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' ); + }; +}; -- 2.30.2