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__