Merge "Fix cookie test to recognize same-site rule"
diff --git a/Changes b/Changes
index bfbfe42..3c9262c 100755
--- a/Changes
+++ b/Changes
@@ -1,9 +1,13 @@
-0.41 2021-01-15
+0.41 2021-01-26
+        - Introduce CORS headers to the proxy.
+        - Introduce Content Security Policy.
         - Remove default api endpoint from config to
           enable changes in the 'Kalamar' config environment
           while keeping the api_path.
-        - Introduce CORS headers to the proxy.
-        - Introduce Content Security Policy.
+        - Added advice in Readme regarding scripts in
+          Windows Powershell (lerepp).
+        - Establish CSP plugin.
+        - Added nonce helper to CSP plugin.
 
 0.40 2020-12-17
         - Modernize ES and fix in-loops.
diff --git a/README.md b/README.md
index 8e1039f..cb07081 100644
--- a/README.md
+++ b/README.md
@@ -275,6 +275,14 @@
 $ cpanm -f Mojolicious::Plugin::MailException
 ```
 
+# Problem running scripts on Windows with Powershell
+
+In case you are having issues with running scripts under Windows,
+you can set the execution policy with
+[`Set-ExecutionPolicy`](https://docs.microsoft.com/de-de/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.1).
+If using the RemoteSigned execution policy, you can use `Unblock-File`
+to allow specific scripts to run.
+
 ## COPYRIGHT AND LICENSE
 
 ### Original Software
diff --git a/lib/Kalamar.pm b/lib/Kalamar.pm
index 9ec798e..b13de68 100644
--- a/lib/Kalamar.pm
+++ b/lib/Kalamar.pm
@@ -119,22 +119,6 @@
     );
   };
 
-  my $csp = $conf->{cs_policy} // (
-    "default-src 'self';".
-      "style-src 'self' 'unsafe-inline';".
-      "frame-src *;".
-      "media-src 'none';".
-      "object-src 'self';".
-      "font-src 'self';".
-      "img-src 'self' data:;"
-    );
-
-  $self->hook(
-    before_render => sub {
-      shift->res->headers->header('Content-Security-Policy' => $csp);
-    }
-  );
-
   # API is not yet set - define the default Kustvakt api endpoint
   $conf->{api_path} //= $ENV{KALAMAR_API} || 'https://korap.ids-mannheim.de/api/';
   $conf->{api_version} //= $API_VERSION;
@@ -159,6 +143,17 @@
     HTML => 1
   });
 
+  # Establish content security policy
+  $self->plugin(CSP => {
+    'default-src' => 'self',
+    'style-src' => ['self','unsafe-inline'],
+    'frame-src' => '*',
+    'media-src' => 'none',
+    'object-src' => 'self',
+    'font-src' => 'self',
+    'img-src' => ['self', 'data:']
+  });
+
   # Localization framework
   $self->plugin(Localize => {
     dict => {
diff --git a/lib/Kalamar/Plugin/CSP.pm b/lib/Kalamar/Plugin/CSP.pm
new file mode 100644
index 0000000..066df33
--- /dev/null
+++ b/lib/Kalamar/Plugin/CSP.pm
@@ -0,0 +1,122 @@
+package Kalamar::Plugin::CSP;
+use Mojo::Base 'Mojolicious::Plugin';
+use Mojo::Base -strict;
+use Mojo::Util qw!quote trim!;
+use List::Util qw'uniq';
+use Mojo::ByteStream 'b';
+
+sub register {
+  my ($plugin, $app, $param) = @_;
+
+  $param ||= {};
+
+  # Load parameter from Config file
+  if (my $config_param = $app->config('CSP')) {
+    $param = { %$param, %$config_param };
+  };
+
+  my $with_nonce = delete $param->{-with_nonce};
+
+  $app->plugin('Util::RandomString' => {
+    nonce => {
+      alphabet => '1234567890abcdefghijklmnopqrstuvwxyz' .
+        'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#?(){}<>+-*',
+      length   => 20
+    }
+  });
+
+  unless ($app->renderer->helpers->{'content_block'}) {
+    $app->plugin('TagHelpers::ContentBlock');
+  };
+
+  # Initialize directives
+  my %directives = ();
+  foreach (keys %$param) {
+    $directives{$_} = ref $param->{$_} eq 'ARRAY' ? $param->{$_} : [$param->{$_}];
+  };
+
+  # Add nonce rule for JS
+  if ($with_nonce) {
+    push(@{$directives{'script-src'} //= []}, 'nonce-{{nonce_js}}');
+  };
+
+  # Generate csp based on directives
+  my $csp = \( generate(%directives) );
+
+  # Add csp header
+  $app->hook(
+    before_dispatch => sub {
+      my $c = shift;
+      if ($$csp) {
+        my $line = $$csp;
+        if ($with_nonce) {
+          $c->stash('csp.nonce' => my $nonce = $c->random_string('nonce'));
+          $line =~ s/\'nonce-\{\{nonce_js\}\}\'/\'nonce-$nonce\'/;
+        };
+        $c->res->headers->header('Content-Security-Policy' => $line);
+      };
+    }
+  );
+
+  # 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;
+      }
+
+      # TODO:
+      #   Probably called from app
+
+      # TODO:
+      #   Check for compliance!
+
+      # Add to static directives
+      push(@{$directives{$dir} //= []}, $url);
+      $csp = \(generate(%directives));
+    }
+  );
+
+  $app->helper(
+    csp_nonce_tag => sub {
+      my $c = shift;
+      unless ($c->content_block_ok('nonce_js')) {
+        return '';
+      };
+
+      if ($with_nonce) {
+        return b(
+          '<script nonce="' . $c->stash('csp.nonce') .
+            qq'">//<![CDATA[\n' . $c->content_block('nonce_js') .
+            "\n//]]></script>");
+      }
+      return b('<!-- inline js permitted -->');
+    }
+  );
+};
+
+
+# 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..1031450
--- /dev/null
+++ b/t/plugin/csp.t
@@ -0,0 +1,154 @@
+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';")
+  ;
+
+
+$t = Test::Mojo->new(Mojolicious::Lite->new);
+$t->app->plugin('Kalamar::Plugin::CSP');
+$t->app->routes->get('/nononce')->to(
+  cb => sub {
+    shift->render(inline => 'Hallo! <%= csp_nonce_tag %>');
+  }
+);
+
+$t->get_ok('/nononce')
+  ->status_is(200)
+  ->content_is("Hallo! \n")
+  ->header_unlike($csp, qr!'nonce-.{20}'!)
+  ;
+
+$t->app->content_block(
+  'nonce_js' => {
+    inline => 'console.log("Hallo")'
+  }
+);
+
+$t->get_ok('/nononce')
+  ->status_is(200)
+  ->content_is("Hallo! <!-- inline js permitted -->\n")
+  ->header_unlike($csp, qr!'nonce-.{20}'!)
+  ;
+
+# Test with nonce:
+$t = Test::Mojo->new(Mojolicious::Lite->new);
+$t->app->config(
+  CSP => {
+    'style-src' => ['self'],
+    'img-src' => ['self', 'data:'],
+    -with_nonce => 1
+  }
+);
+
+$t->app->plugin('Kalamar::Plugin::CSP');
+
+$t->app->routes->get('/nonce')->to(
+  cb => sub {
+    shift->render(inline => 'Hallo! <%= csp_nonce_tag %>');
+  }
+);
+
+$t->get_ok('/nonce')
+  ->status_is(200)
+  ->content_like(qr'Hallo!')
+  ->content_unlike(qr'<script nonce=".{20}">')
+  ->header_like($csp, qr!^img-src 'self' data:;script-src 'nonce-.{20}';style-src 'self';!)
+  ->tx->res->to_string;
+;
+
+$t->app->content_block(
+  'nonce_js' => {
+    inline => 'console.log("Hallo")'
+  }
+);
+
+my $content = $t->get_ok('/nonce')
+  ->status_is(200)
+  ->content_like(qr'Hallo! <script nonce=".{20}">//<!\[CDATA\[\nconsole')
+  ->header_like($csp, qr!^img-src 'self' data:;script-src 'nonce-.{20}';style-src 'self';!)
+  ->tx->res->to_string;
+;
+
+$content =~ q!<script nonce="(.{20})"!;
+like($content, qr/nonce-\Q$1\E/);
+
+
+
+done_testing;
+__END__