Hasegawa方式の CSRF対策を試してみた

Amon2 での実装例です。

使用感としては、

  • 実装はそれほどむずかしくない
  • トークンの保存をサーバー側でやらなくていいので楽

といったかんじ。

管理画面とかでつかってみたらよいかもしれぬ。

use strict;
use warnings;
use utf8;
use File::Spec;
use File::Basename;
use lib File::Spec->catdir(dirname(__FILE__), 'extlib', 'lib', 'perl5');
use lib File::Spec->catdir(dirname(__FILE__), 'lib');
use Amon2::Lite;

{
    package Amon2::Plugin::Web::Hsegawa;
    use constant {
        REDIRECT            => 10001,
        VALIDATION_ERROR    => 10002,
    };

    sub init {
        my ($class, $c, $conf) = @_;

        $c->add_trigger(
            BEFORE_DISPATCH => sub {
                my $c = shift;
                my $method = $c->req->method;
                if ($method ne 'GET' && $method ne 'HEAD') {
                    unless (_validate_hsegawa($c)) {
                        my $res = $c->render_json(
                            {error => 'CSRF Detected.'},
                        );
                        return $res;
                    }
                }
            },
        );

        Amon2::Util::add_method($c, 'post_redirect', \&post_redirect);
        Amon2::Util::add_method($c, 'post_validation_failed', \&post_validation_failed);
    }

    sub post_redirect {
        my $self = shift;
        my $res = $self->redirect(@_);
        return $self->render_json(
            {
                code => REDIRECT,
                location => scalar($res->header('Location')),
            }
        );
    }

    sub post_validation_failed {
        my $self = shift;
        return $self->render_json(
            {
                code => VALIDATION_ERROR,
                message => $_[0],
            }
        );
    }

    sub _validate_hsegawa {
        my $c = shift;
        return 0 unless defined $c->req->header('X-From');
        return 1 unless defined($c->req->header('Origin'));
        return 1 if _is_same_origin(scalar($c->req->header('Origin')), scalar($c->req->header('X-From')));
        return 0;
    }

    sub _is_same_origin {
        my ($a, $b) = @_;

        $a = URI->new($a);
        $b = URI->new($b);

        return (
            $a->scheme eq $b->scheme
            && $a->host eq $b->host
            && $a->port eq $b->port
        );
    }
}

our $VERSION = '0.01';

my @ENTRIES;

sub load_config { +{} }

get '/' => sub {
    my $c = shift;
    return $c->render('index.tt', {
        entries => \@ENTRIES,
    });
};

post '/inquiry' => sub {
    my $c = shift;
    my $msg = $c->req->param('msg');
    if ($msg =~ /fuck/) {
        return $c->post_validation_failed('Bad word');
    }
    warn "POSTTTTTTTTTTTTTTTTTTT: $msg";
    unshift @ENTRIES, $msg;
    return $c->post_redirect('/');
};

# load plugins
__PACKAGE__->load_plugin('Web::JSON');
Amon2::Plugin::Web::Hsegawa->init(__PACKAGE__);

__PACKAGE__->enable_session();

__PACKAGE__->to_app(handle_static => 1);

__DATA__

@@ index.tt
<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>hsegawa::CSRFTest</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.8.0/jquery.min.js"></script>
    <link rel="stylesheet" href="http://twitter.github.com/bootstrap/1.4.0/bootstrap.min.css">
    <link rel="stylesheet" href="[% uri_for('/static/css/main.css') %]">
</head>
<body>
    <div class="container">
        <header><h1>hsegawa::CSRFTest</h1></header>
        <section class="row">
            <form method="post" action="/inquiry" class="csrf">
                <input type="text" name="msg">
                <input type="submit" value="Send" class="btn">
            </form>
        </section>
        <div>
            [% FOR e IN entries %]
                [% e %]<br />
            [% END %]
        </div>
        <footer>Powered by <a href="http://amon.64p.org/">Amon2::Lite</a></footer>
    </div>
    <script>
        "use strict";

        $.fn.hsegawa = function (opts) {
            var form = $(this);

            form.submit(function () {
                $.ajax({
                    url: form.attr('action'),
                    type: form.attr('method'),
                    data: form.serialize(),
                    headers: {
                        'X-From': location.href
                    },
                    dataType: 'json'
                }).success(function (res) {
                    if (res.code == 10001) {
                        opts.redirect(res);
                    } else if (res.code == 10002) {
                        opts.validation_failed(res);
                    }
                }).error(function (res) {
                    opts.error(res);
                });
                return false;
            });
        };

        $('form.csrf').hsegawa({
            redirect: function (res) {
                location.href = res.location;
            },
            validation_failed: function (res) {
                alert(res.message);
            },
            error: function (res) {
                alert("ERROR: " + x);
            }
        });
    </script>
</body>
</html>

@@ /static/css/main.css
footer {
    text-align: right;
}