Establish CSP plugin

Change-Id: Iffb988f3c6a022ab20e64a1dcbd96f9cc6f96cb4
diff --git a/Changes b/Changes
index 0e58320..66c56ae 100755
--- a/Changes
+++ b/Changes
@@ -6,6 +6,7 @@
           while keeping the api_path.
         - Added advice in Readme regarding scripts in
           Windows Powershell (lerepp).
+        - Establish CSP plugin.
 
 0.40 2020-12-17
         - Modernize ES and fix in-loops.
diff --git a/lib/Kalamar/Plugin/CSP.pm b/lib/Kalamar/Plugin/CSP.pm
new file mode 100644
index 0000000..440642d
--- /dev/null
+++ b/lib/Kalamar/Plugin/CSP.pm
@@ -0,0 +1,76 @@
+package Kalamar::Plugin::CSP;
+use Mojo::Base 'Mojolicious::Plugin';
+use Mojo::Base -strict;
+use Mojo::Util qw!quote trim!;
+use List::Util qw'uniq';
+
+sub register {
+  my ($plugin, $app, $param) = @_;
+
+  $param ||= {};
+
+  # Load parameter from Config file
+  if (my $config_param = $app->config('CSP')) {
+    $param = { %$param, %$config_param };
+  };
+
+  # Initialize directives
+  my %directives = ();
+  foreach (keys %$param) {
+    $directives{$_} = ref $param->{$_} eq 'ARRAY' ? $param->{$_} : [$param->{$_}];
+  };
+
+  # Generate csp based on directives
+  my $csp = \( generate(%directives) );
+
+  # Add csp header
+  $app->hook(
+    before_dispatch => sub {
+      my $c = shift;
+      if ($$csp) {
+        $c->res->headers->header('Content-Security-Policy' => $$csp);
+      };
+    }
+  );
+
+  # Add csp directives
+  $app->helper(
+    'csp.add' => sub {
+      my ($c, $dir, $url) = @_;
+
+      if ($c->tx->{req}) {
+
+        # Add template to content block
+        # @{$c->stash->{'csp.' . $name} ...
+        $c->app->log->warn('Calling csp.add from controller not yet supported');
+        return;
+      }
+
+      # Probably called from app
+
+      # Add to static directives
+      push(@{$directives{$dir} //= []}, $url);
+      $csp = \(generate(%directives));
+    }
+  );
+};
+
+
+# Quote elements that need to be single quoted
+sub opt_quote {
+  my $s = trim(shift);
+  if ($s =~ /^(?:(?:self|none|unsafe-(?:inline|eval|hashes))$|nonce-|sha(?:256|384|512)-)/) {
+    return qq!'$s'!;
+  };
+  return $s;
+};
+
+
+# Generate CSP string
+sub generate {
+  return '' unless @_;
+  my %d = @_;
+  return join(';', map { $_ . ' ' . join(' ', map { opt_quote($_) } uniq @{$d{$_}}) } sort keys %d) . ';';
+};
+
+1;
diff --git a/t/plugin/csp.t b/t/plugin/csp.t
new file mode 100644
index 0000000..6e15a61
--- /dev/null
+++ b/t/plugin/csp.t
@@ -0,0 +1,84 @@
+use Mojolicious::Lite;
+use Test::Mojo;
+use Test::More;
+
+my $t = Test::Mojo->new;
+
+plugin 'Kalamar::Plugin::CSP' => {
+  'style-src' => ['self','unsafe-inline'],
+  'script-src' => '*',
+  'img-src' => ['self', 'data:']
+};
+
+get '/' => sub {
+  shift->render(text => 'hello world');
+};
+
+my $csp = 'Content-Security-Policy';
+
+$t->get_ok('/')
+  ->status_is(200)
+  ->content_is('hello world')
+  ->header_is($csp, "img-src 'self' data:;script-src *;style-src 'self' 'unsafe-inline';")
+  ;
+
+$t->app->csp->add('img-src' => 'stats.ids-mannheim.de');
+
+$t->get_ok('/')
+  ->status_is(200)
+  ->content_is('hello world')
+  ->header_is($csp, "img-src 'self' data: stats.ids-mannheim.de;script-src *;style-src 'self' 'unsafe-inline';")
+  ;
+
+$t->get_ok('/')
+  ->status_is(200)
+  ->content_is('hello world')
+  ->header_is($csp, "img-src 'self' data: stats.ids-mannheim.de;script-src *;style-src 'self' 'unsafe-inline';")
+  ;
+
+$t->app->csp->add('img-src' => 'stats.ids-mannheim.de');
+
+$t->get_ok('/')
+  ->status_is(200)
+  ->content_is('hello world')
+  ->header_is($csp, "img-src 'self' data: stats.ids-mannheim.de;script-src *;style-src 'self' 'unsafe-inline';")
+  ;
+
+$t->app->csp->add('script-src' => '*');
+
+$t->get_ok('/')
+  ->status_is(200)
+  ->content_is('hello world')
+  ->header_is($csp, "img-src 'self' data: stats.ids-mannheim.de;script-src *;style-src 'self' 'unsafe-inline';")
+  ;
+
+
+# New
+$t = Test::Mojo->new;
+$t->app->config(
+  CSP => {
+    'style-src' => ['self','unsafe-inline'],
+    'img-src' => ['self', 'data:']
+  }
+);
+
+$t->app->plugin('Kalamar::Plugin::CSP' => {
+  'script-src' => '*',
+  'img-src' => 'self'
+});
+
+$t->app->routes->get('/n')->to(
+  cb => sub {
+    shift->render(text => 'hello world');
+  }
+);
+
+$t->get_ok('/n')
+  ->status_is(200)
+  ->content_is('hello world')
+  ->header_is($csp, "img-src 'self' data:;script-src *;style-src 'self' 'unsafe-inline';")
+  ;
+
+
+done_testing;
+__END__