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__