From 46525a4dd66a4023e7020f5f3f34a7fc4c96a1bd Mon Sep 17 00:00:00 2001 From: Francesc Guasch Date: Thu, 26 Oct 2023 14:52:01 +0200 Subject: [PATCH] Fix/1920 csp (#2003) fix(frontend): CSP more accurate Define the directives for our CDNs. More info about it: https://content-security-policy.com/ closes #1920 Co-authored-by: Fernando Verdugo --- .gitignore | 1 + etc/rvd_front.conf.example | 3 + lib/Ravada.pm | 53 ++++++++++++ lib/Ravada/Front.pm | 19 ++++- public/js/admin.js | 25 +++++- script/rvd_front | 48 +++++++++-- t/mojo/40_security_policy.t | 85 ++++++++++++++++++++ templates/bootstrap/header.html.ep | 2 +- templates/main/admin_settings.html.ep | 65 ++++++++++----- templates/main/admin_settings_submit.html.ep | 13 +++ 10 files changed, 284 insertions(+), 30 deletions(-) create mode 100644 t/mojo/40_security_policy.t create mode 100644 templates/main/admin_settings_submit.html.ep diff --git a/.gitignore b/.gitignore index f52e0a830..2d8e3ba74 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ log rvd_front.conf pkg-debian-out public/img/screenshots +public/js/custom yarn.lock node_modules/ t/vm/b10* diff --git a/etc/rvd_front.conf.example b/etc/rvd_front.conf.example index 7f0c2679f..369aa24dc 100644 --- a/etc/rvd_front.conf.example +++ b/etc/rvd_front.conf.example @@ -40,4 +40,7 @@ # Insert widget in /js/custom/insert_here_widget.js # this widget embed js in templates/bootstrap/scripts.html.ep ,widget => '' +# Content-Security-Policy HTTP response header helps you reduce XSS risks +# define custom directives. More info https://content-security-policy.com/ + ,security_policy => 'foo.bar.com' }; diff --git a/lib/Ravada.pm b/lib/Ravada.pm index 6c7305d6e..3b1287872 100644 --- a/lib/Ravada.pm +++ b/lib/Ravada.pm @@ -2543,6 +2543,59 @@ sub _sql_insert_defaults($self){ ,name => 'auto_view' ,value => $conf->{auto_view} } + ,{ id_parent => $id_frontend + ,name => "widget" + } + ,{ + id_parent => $id_frontend + ,name => 'content_security_policy' + } + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "all" + ,value => '' + } + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "default-src" + ,value => '' + } + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "style-src" + ,value => '' + } + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "script-src" + ,value => '' + } + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "object-src" + ,value => '' + } + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "frame-src" + ,value => '' + } + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "font-src" + ,value => '' + } + + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "connect-src" + ,value => '' + } + ,{ + id_parent => "/frontend/content_security_policy" + ,name => "media-src" + ,value => '' + } ,{ id_parent => $id_backend ,name => 'start_limit' diff --git a/lib/Ravada/Front.pm b/lib/Ravada/Front.pm index 6ea3f8ffe..945071d84 100644 --- a/lib/Ravada/Front.pm +++ b/lib/Ravada/Front.pm @@ -16,6 +16,7 @@ use Hash::Util qw(lock_hash); use IPC::Run3 qw(run3); use JSON::XS; use Moose; +use Storable qw(dclone); use Ravada; use Ravada::Auth::LDAP; use Ravada::Front::Domain; @@ -1481,6 +1482,19 @@ sub _settings_by_id($self) { return $orig_settings; } +sub _settings_by_parent($self,$parent) { + my $data = $self->_setting_data($parent); + my $sth = $self->_dbh->prepare("SELECT name,value FROM settings " + ." WHERE id_parent = ? "); + $sth->execute($data->{id}); + my $ret; + while (my ($name, $value) = $sth->fetchrow) { + $value = '' if !defined $value; + $ret->{$name} = $value; + } + return $ret; +} + =head2 feature Returns if a feature is available @@ -1521,9 +1535,10 @@ sub update_settings_global($self, $arg, $user, $reload, $orig_settings = $self-> confess Dumper([$field,$arg->{$field}]) if !ref($arg->{$field}); if ( scalar(keys %{$arg->{$field}})>2 ) { confess if !keys %{$arg->{$field}}; - $self->update_settings_global($arg->{$field}, $user, $reload, $orig_settings); + my $field2 = dclone($arg->{$field}); + $self->update_settings_global($field2, $user, $reload, $orig_settings); } - confess "Error: invalid field $field" if $field !~ /^\w+$/; + confess "Error: invalid field $field" if $field !~ /^\w[\w\-]+$/; my ( $value, $id ) = ($arg->{$field}->{value} , $arg->{$field}->{id} diff --git a/public/js/admin.js b/public/js/admin.js index aa43e9136..d7f84318b 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1453,7 +1453,27 @@ ravadaApp.directive("solShowMachine", swMach) function settings_global_ctrl($scope, $http) { $scope.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - $scope.init = function() { + $scope.csp_locked = false; + $scope.set_csp_locked=function() { + var keys = Object.keys($scope.settings.frontend.content_security_policy); + var found = 0; + for ( var n_key=0 ; n_key0; + if ($scope.csp_locked && !$scope.csp_advanced) { + $scope.csp_advanced = true; + } + }; + $scope.init = function(url, csp_advanced) { + $scope.csp_advanced=false; + if (csp_advanced) { + $scope.csp_advanced=true; + } $http.get('/settings_global.json').then(function(response) { $scope.settings = response.data; var now = new Date(); @@ -1472,10 +1492,12 @@ ravadaApp.directive("solShowMachine", swMach) $scope.settings.frontend.maintenance_end.value =new Date($scope.settings.frontend.maintenance_end.value); } + $scope.set_csp_locked(); }); }; $scope.load_settings = function() { $scope.init(); + $scope.set_csp_locked(); $scope.formSettings.$setPristine(); }; $scope.update_settings = function() { @@ -1483,6 +1505,7 @@ ravadaApp.directive("solShowMachine", swMach) $http.post('/settings_global' ,JSON.stringify($scope.settings) ).then(function(response) { + $scope.set_csp_locked(); if (response.data.reload) { window.location.reload(); } diff --git a/script/rvd_front b/script/rvd_front index 0c7242576..44f60cce3 100644 --- a/script/rvd_front +++ b/script/rvd_front @@ -147,21 +147,50 @@ sub _time() { return strftime('%Y/%m/%d:%H:%M:%S %z',localtime); } +sub _security_policy() { + + my $config=$RAVADA->_settings_by_parent("/frontend/content_security_policy"); + my $all = ($config->{all} or ''); + my %src = ( + "default-src" => "'self' sha256- sha384 http: https: data:" + ,"style-src" => "'self' cdnjs.cloudflare.com stackpath.bootstrapcdn.com cdn.ckeditor.com cdn.jsdelivr.net use.fontawesome.com 'unsafe-inline'" + ,"script-src" => "'self' code.jquery.com cdn.ckeditor.com cdnjs.cloudflare.com stackpath.bootstrapcdn.com ajax.googleapis.com cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'" + ,"object-src" => " 'self'" + ,"media-src" => "'self'" + ,"frame-src" => "'self'" + ,"font-src" => "'self' data: use.fontawesome.com" + ,"connect-src" => "'self'" + ); + my $sec = ''; + for my $field (sort keys %src) { + $sec .= " " if $sec; + $sec .= $field." ".$src{$field}; + $sec .= " $all" if $all; + if ( exists $config->{$field} ) { + $sec .= " ".${config}->{$field}; + } + $field =~ s/-/_/g; + if ( exists $config->{$field} ) { + $sec .= " ".$config->{$field}; + } + + $sec .= ";"; + } + return $sec; +} + hook before_routes => sub { my $c = shift; - $c ->res->headers->content_security_policy ( - "object-src 'auto';" - ."media-src 'self';" - ." frame-src 'self';" - ." connect-src 'self'; "); - + my $sec = _security_policy(); + $c ->res->headers->content_security_policy($sec); $USER = undef; $c->stash(version => $RAVADA->version); my $url = $c->req->url->to_abs->path; my $host = $c->req->url->to_abs->host; + my $widget=( $CONFIG_FRONT->{widget} or $RAVADA->setting('/frontend/widget')); $c->stash(css=>['/css/sb-admin.css'] ,js_mod=>[ ## angular modules '/js/booking/booking.module.js?v='.$RAVADA->version @@ -184,7 +213,7 @@ hook before_routes => sub { ,host => $host ,bookings => $RAVADA->setting('/backend/bookings') ,FEATURE => {} - ,widget => $CONFIG_FRONT->{widget} + ,widget => $widget ); $USER = _logged_in($c); @@ -2810,6 +2839,11 @@ sub admin { return access_denied($c) unless $USER->is_admin; my $url = $c->req->url->to_abs->path; my $host = $c->req->url->to_abs->host; + my $csp = $RAVADA->_settings_by_parent("/frontend/content_security_policy"); + my $csp_advanced = 0; + $csp_advanced = grep $csp->{$_}, grep /-/,keys %$csp; + + $c->stash( csp => $csp , csp_advanced => $csp_advanced); $c->stash(url_login => "/login"); } if ($page eq 'storage') { diff --git a/t/mojo/40_security_policy.t b/t/mojo/40_security_policy.t new file mode 100644 index 000000000..4e4af063d --- /dev/null +++ b/t/mojo/40_security_policy.t @@ -0,0 +1,85 @@ +use warnings; +use strict; + +use Carp qw(confess); +use Data::Dumper; +use HTML::Lint; +use Test::More; +use Test::Mojo; +use Mojo::File 'path'; +use Mojo::JSON qw(decode_json); +use Storable qw(dclone); + +use lib 't/lib'; +use Test::Ravada; + +no warnings "experimental::signatures"; +use feature qw(signatures); + +my $SECONDS_TIMEOUT = 15; + +my $t; + +my $URL_LOGOUT = '/logout'; +my ($USERNAME, $PASSWORD) = (user_admin->name, "$$ $$"); +my $SCRIPT = path(__FILE__)->dirname->sibling('../script/rvd_front'); + +$ENV{MOJO_MODE} = 'devel'; +init('/etc/ravada.conf',0); +my $connector = rvd_back->connector; +like($connector->{driver} , qr/mysql/i) or BAIL_OUT; + +$Test::Ravada::BACKGROUND=1; + +$t = Test::Mojo->new($SCRIPT); +$t->ua->inactivity_timeout(900); +$t->ua->connect_timeout(60); + +mojo_login($t, $USERNAME, $PASSWORD); + +my $sth = rvd_front->_dbh->prepare("UPDATE settings set value='' WHERE id_parent=?"); + +$t->get_ok("/settings_global.json")->status_is(200); +my $body = $t->tx->res->body(); +my $settings = decode_json($body); + +$sth->execute($settings->{frontend}->{content_security_policy}->{id}); + +my $new = dclone($settings); +my $exp_default = "foodefault.example.com"; +my $exp_all = "fooall.example.com"; +$new->{frontend}->{content_security_policy}->{'default-src'}->{value} = $exp_default; +$new->{frontend}->{content_security_policy}->{'all'}->{value} = $exp_all; +delete $new->{backend}; + +my $reload=0; +rvd_front->update_settings_global($new,user_admin,$reload); + +$t->post_ok("/settings_global", json => $new ); + +$t->get_ok("/settings_global.json")->status_is(200); +$body = $t->tx->res->body(); +my $settings2 = decode_json($body); +is($settings2->{frontend}->{content_security_policy}->{'all'}->{value} , $exp_all) or exit; +is($settings2->{frontend}->{content_security_policy}->{'default-src'}->{value} , $exp_default) or exit; + +my $config_csp = rvd_front->_settings_by_parent("/frontend/content_security_policy"); +is($config_csp->{all}, $exp_all); +is($config_csp->{'default-src'}, $exp_default); + +my $header = $t->tx->res->headers->content_security_policy(); +my %csp; +for my $entry (split /;/,$header) { + my ($key,$value) = $entry =~ /\s*(.*?)\s+(.*)/; + $csp{$key}=$value; +} + +like($csp{'default-src'},qr/$exp_all/); +like($csp{'default-src'},qr/$exp_default/); + +$sth->execute($settings->{frontend}->{content_security_policy}->{id}); + +$new->{frontend}->{content_security_policy}->{'default-src'}->{value} = ''; +$new->{frontend}->{content_security_policy}->{'all'}->{value} = ''; +$t->post_ok("/settings_global", json => $new ); +done_testing(); diff --git a/templates/bootstrap/header.html.ep b/templates/bootstrap/header.html.ep index fc50da478..afcd3049f 100644 --- a/templates/bootstrap/header.html.ep +++ b/templates/bootstrap/header.html.ep @@ -12,7 +12,7 @@ Ravada VDI % if ( !$fallback ) { - + diff --git a/templates/main/admin_settings.html.ep b/templates/main/admin_settings.html.ep index c737fa445..b44a125f5 100644 --- a/templates/main/admin_settings.html.ep +++ b/templates/main/admin_settings.html.ep @@ -6,7 +6,8 @@ %= include 'bootstrap/navigation'
-
-
+
@@ -71,7 +71,7 @@
-
+
@@ -79,6 +79,47 @@ type="datetime-local">
+ +
+
+
<%=l 'Widget' %> + +
+
+ +
+
+ +
+
Content Security Policy
+
+
+
+
+
+
+
+ + +
+
+
+ % for my $item (sort keys %$csp) { +
+
+
<%= $item %>
+
+ +
+
+ % } +
+ +%= include "/main/admin_settings_submit" +
@@ -211,21 +252,7 @@
-
- -
-
- - -
-
- +%= include "/main/admin_settings_submit"
diff --git a/templates/main/admin_settings_submit.html.ep b/templates/main/admin_settings_submit.html.ep new file mode 100644 index 000000000..faa325bc7 --- /dev/null +++ b/templates/main/admin_settings_submit.html.ep @@ -0,0 +1,13 @@ +
+
+
+ + +
+