Added nonce helper to CSP plugin

Change-Id: I78b48e84222efe348abecb5d45d4f5c8d59d7335
diff --git a/Changes b/Changes
index 66c56ae..3c9262c 100755
--- a/Changes
+++ b/Changes
@@ -1,4 +1,4 @@
-0.41 2021-01-25
+0.41 2021-01-26
         - Introduce CORS headers to the proxy.
         - Introduce Content Security Policy.
         - Remove default api endpoint from config to
@@ -7,6 +7,7 @@
         - 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/lib/Kalamar/Plugin/CSP.pm b/lib/Kalamar/Plugin/CSP.pm
index 440642d..066df33 100644
--- a/lib/Kalamar/Plugin/CSP.pm
+++ b/lib/Kalamar/Plugin/CSP.pm
@@ -3,6 +3,7 @@
 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) = @_;
@@ -14,12 +15,31 @@
     $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) );
 
@@ -28,7 +48,12 @@
     before_dispatch => sub {
       my $c = shift;
       if ($$csp) {
-        $c->res->headers->header('Content-Security-Policy' => $$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);
       };
     }
   );
@@ -46,13 +71,34 @@
         return;
       }
 
-      # Probably called from app
+      # 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 -->');
+    }
+  );
 };
 
 
diff --git a/t/plugin/csp.t b/t/plugin/csp.t
index 6e15a61..1031450 100644
--- a/t/plugin/csp.t
+++ b/t/plugin/csp.t
@@ -80,5 +80,75 @@
   ;
 
 
+$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__