RSS Git Download  Clone
Raw Blame History
package Local::Dancer2::CSRF;

# port of Dancer2::Plugin::CSRF

# Dancer2::Plugin::CSRF doesn't work well with DP::Deferred which appends
# '?dpdid=.....' to form submission which breaks token validation, it also
# uses deprecated dsl keywords (eg request->dispatch_path)

# to use $self->dsl->debug, need to use private subs, or caller is
# ~/perl5/lib/perl5/Dancer2/Plugin.pm for debug output

# set log level to debug in development config to see _debug() output

use 5.26.0;

use Term::ANSIColor::Simple;
use Crypt::SaltedHash;
use Dancer2::Plugin;
use Data::Printer;
use Data::UUID;

my $HASHER = Crypt::SaltedHash->new( algorithm => 'SHA-1' ); # SHA1 is default anyway
my $UUID   = Data::UUID->new();

plugin_keywords qw( get_csrf_token check_csrf_token );

has session_key_name => (
    is      => 'ro', # this plugin config session_key_name if set:
    default => sub { $_[0]->config->{session_key_name} || 'csrf_token' }
);

# caller[1] is ~/[..]/Dancer2/Plugin.pm for DSL debug output, solution is to
# wrap private subs, then caller is this class
sub check_csrf_token { _check_csrf_token(@_) }
sub get_csrf_token   { _get_csrf_token(@_)   }

#===============================================================================
sub _get_csrf_token { # caller[1] is this class
    my $self = shift;

    my $app = $self->app; # p $app->session;
    # my $dsl = $app->dsl; $dsl->debug("form_url:$form_url"); using _debug() now:
            $self->_debug(__LINE__,'getting csrf token .....');
    my $key_name = $self->session_key_name;
            $self->_debug(__LINE__,"key_name:$key_name");
    my $session  = $app->session;            # p $session;

    # create a session $key_name => value pair if it doesn't exist:
    if (! $session->read($key_name) ) {
                $self->_debug(__LINE__,"session lacks $key_name key");
        my $str = $UUID->create_str();
                $self->_debug(__LINE__,"creating new $key_name:$str pair");
        $session->write( $key_name => $str );
    } # p $session;
    else { $self->_debug(__LINE__,"session contains $key_name key") }
    # generate a new token:
    my $token = $self->_generate_token; # $dsl->debug("token:$token"); already done it
    return $token;
}

# check whether submitted form token matches its expected value, then delete
# csrf key value from session to block back-button resubmission:
sub _check_csrf_token { # caller[1] is this class
    my ($self, $form_token) = @_; # p $form_token;
            $self->_debug(__LINE__,"checking csrf token .....");
    # generate expected token (using same function as original form) for
    # comparison with $form_token (return a failure if no session key):
    my $expected_token = $self->_generate_token || return 0; # p $expected_token;
            $self->_debug(__LINE__,"expected token:$expected_token");
            $self->_debug(__LINE__,"received token:$form_token");
    # clear csrf-token from session to prevent back-button resubmission:
    my $key_name = $self->session_key_name;    # p $key_name;
            $self->_debug(__LINE__,"deleting session key $key_name");
    $self->app->session->delete($key_name);

    # returns boolen truth:
    return $form_token eq $expected_token;
}

# generate a salted-hash token using session $key_name value & form_url value:
sub _generate_token { # caller[1] is this class
    my $self = shift; # p $self->app->session;

    my $app = $self->app; # p $app->session; p $app->environment;
            $self->_debug(__LINE__,'creating new token');
    my $session_key_name = $self->session_key_name; # p $session_key_name;
            $self->_debug(__LINE__,"session_key_name:$session_key_name");
    my $session_key_value = $app->session->read($session_key_name) || return 0;
            $self->_debug(__LINE__,"session_key_value:$session_key_value"); # p $session_key_value;

=begin
p $app->request->path;         # eg "/upload"
p $app->request->request_uri;  # eg "/upload?dpdid=59091261"
p $app->request->base;         # URI::http object, stringifies to "http://localhost:3000/"
p $app->request->uri_base;     # eg "http://localhost:3000"
=cut
    my $request = $app->request; # p $request->referer;
    # this needs to work for both initial get & associated post requests:
    #my $form_url = do {
    #    ( my $path = $request->path ) =~ s{^/}{}; # p $path;
    #    $request->base . $path;
    #}; $self->_debug(__LINE__,"form_url:$form_url");

    my $form_url = $request->uri_base . $request->path; # p $form_url;
            $self->_debug(__LINE__,"form_url:$form_url");
    my $token = $HASHER->add($session_key_value, $form_url)->generate(); # p $token;
    $HASHER->clear();
            $self->_debug(__LINE__,"created new token:$token");
    return $token;
}

sub _debug {
    my ($self, $line, $str) = @_; # p $str;
    my $app = $self->app; # p $app->logger_engine->log_level;
    my @caller = caller(1); # p @caller;
    # require development env with log level core or debug:
    return unless $app->environment eq 'development'
       && grep { $app->logger_engine->log_level eq $_ } qw(core debug);
    say color($caller[0] . ' line #' . $line)->red;
    say color($str)->green;
}

1;