From cbd600ec8cd12af8fc89205cabcf4e64f833b994 Mon Sep 17 00:00:00 2001 From: Lucas Marcouiller <45882981+Hystepik@users.noreply.github.com> Date: Thu, 14 Aug 2025 04:06:11 +0200 Subject: [PATCH] NEW Add a page to edit http security headers of application (#34941) * New main http security headers page * fix CI * Fix CI --------- Co-authored-by: Lucas Marcouiller Co-authored-by: Laurent Destailleur --- htdocs/admin/security_headers_http.php | 422 +++++++++++++++++++++++++ htdocs/core/lib/admin.lib.php | 156 +++++++++ htdocs/langs/en_US/admin.lang | 10 + 3 files changed, 588 insertions(+) create mode 100644 htdocs/admin/security_headers_http.php diff --git a/htdocs/admin/security_headers_http.php b/htdocs/admin/security_headers_http.php new file mode 100644 index 00000000000..78f72464f25 --- /dev/null +++ b/htdocs/admin/security_headers_http.php @@ -0,0 +1,422 @@ + + * Copyright (C) 2005-2012 Regis Houssin + * Copyright (C) 2013 Juanjo Menent + * Copyright (C) 2024 Frédéric France + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file htdocs/admin/security_headers_http.php + * \ingroup core + * \brief Security options setup + */ + +// Load Dolibarr environment +require '../main.inc.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Load translation files required by the page +$langs->loadLangs(array("users", "admin", "other")); + +if (!$user->admin) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); + +$forceCSP = getDolGlobalString("MAIN_SECURITY_FORCECSP"); +$selectarrayCSPDirectives = GetContentPolicyDirectives(); +$selectarrayCSPSources = GetContentPolicySources(); +$forceCSPArr = GetContentPolicyToArray($forceCSP); +$error = 0; + +/* + * Actions + */ + +if (preg_match('/set_([a-z0-9_\-]+)/i', $action, $reg)) { + $code = $reg[1]; + $value = (GETPOST($code, 'alpha') ? GETPOST($code, 'alpha') : 1); + if (dolibarr_set_const($db, $code, $value, 'chaine', 0, '', $conf->entity) > 0) { + header("Location: ".$_SERVER["PHP_SELF"]); + exit; + } else { + dol_print_error($db); + } +} elseif (preg_match('/del_([a-z0-9_\-]+)/i', $action, $reg)) { + $code = $reg[1]; + if (dolibarr_del_const($db, $code, $conf->entity) > 0) { + header("Location: ".$_SERVER["PHP_SELF"]); + exit; + } else { + dol_print_error($db); + } +} elseif ($action == 'removecspsource') { + $db->begin(); + $sourcetype = ""; + $sourcecsp = explode("_", GETPOST("sourcecsp")); + $directive = $sourcecsp[0]; + $sourcekey = isset($sourcecsp[1]) ? $sourcecsp[1] : null; + $sourcedata = isset($sourcecsp[2]) ? $sourcecsp[2] : null; + $forceCSPArr = GetContentPolicyToArray($forceCSP); + $directivesarray = GetContentPolicyDirectives(); + $sourcesarray = GetContentPolicySources(); + if (empty($directive)) { + $error++; + } + + if (!empty($directivesarray[$directive])) { + $directivetype = (string) $directivesarray[$directive]["data-directivetype"]; + if (isset($sourcekey)) { + $sourcetype = $sourcesarray[$directivetype][$sourcekey]["data-sourcetype"]; + } + } + + $securityspstring = ""; + if (!$error && !empty($forceCSPArr)) { + if (isset($sourcekey) && !empty($forceCSPArr[$directive][$sourcekey])) { + unset($forceCSPArr[$directive][$sourcekey]); + } + if (count($forceCSPArr[$directive]) == 0) { + unset($forceCSPArr[$directive]); + } + foreach ($forceCSPArr as $directive => $sourcekeys) { + if ($securityspstring != "") { + $securityspstring .= "; "; + } + $sourcestring = ""; + foreach ($sourcekeys as $key => $source) { + $directivetype = $directivesarray[$directive]["data-directivetype"]; + $sourcetype = $sourcesarray[$directivetype][$source]["data-sourcetype"]; + if ($sourcetype == "quoted") { + $sourcestring .= " '".$source."'"; + } else { + $sourcestring .= " ".$source; + } + } + $securityspstring .= $directive . $sourcestring; + } + $res = dolibarr_set_const($db, 'MAIN_SECURITY_FORCECSP', $securityspstring, 'chaine', 0, '', $conf->entity); + if ($res <= 0) { + $error++; + } + } + + if (!$error) { + $db->commit(); + setEventMessages($langs->trans("MainSecurityPolicySucesfullyRemoved"), null, 'mesgs'); + } else { + $db->rollback(); + setEventMessages($langs->trans("MainErrorRemovingSecurityPolicy"), null, 'errors'); + } + + header("Location: ".$_SERVER["PHP_SELF"]); + exit(); +} elseif ($action == "updateform" && GETPOST("btn_MAIN_SECURITY_FORCECSP")) { + $directivecsp = GETPOST("select_identifier_MAIN_SECURITY_FORCECSP"); + $sourcecsp = GETPOST("select_source_MAIN_SECURITY_FORCECSP"); + $sourcedatacsp = GETPOST("input_data_MAIN_SECURITY_FORCECSP"); + $sourcetype = ""; + + $forceCSPArr = GetContentPolicyToArray($forceCSP); + $directivesarray = GetContentPolicyDirectives(); + $sourcesarray = GetContentPolicySources(); + if (empty($directivecsp)) { + $error++; + } + if ($error || (!isset($sourcecsp) && $directivesarray[$directivecsp]["data-directivetype"] != "none")) { + $error++; + } + if (!$error) { + $directivetype = $directivesarray[$directivecsp]["data-directivetype"]; + if (isset($sourcecsp)) { + $sourcetype = $sourcesarray[$directivetype][$sourcecsp]["data-sourcetype"]; + } + $securityspstring = ""; + if (isset($sourcetype) && $sourcetype == "data") { + $forceCSPArr[$directivecsp][] = "data:".$sourcedatacsp; + } elseif (isset($sourcetype) && $sourcetype == "input") { + if (empty($forceCSPArr[$directivecsp])) { + $forceCSPArr[$directivecsp] = array(); + } + $forceCSPArr[$directivecsp] = array_merge(explode(" ", $sourcedatacsp), $forceCSPArr[$directivecsp]); + } else { + if (empty($forceCSPArr[$directivecsp])) { + $forceCSPArr[$directivecsp] = array(); + } + if (!isset($sourcecsp)) { + $sourcecsp = ""; + } + array_unshift($forceCSPArr[$directivecsp], $sourcecsp); + } + foreach ($forceCSPArr as $directive => $sourcekeys) { + if ($securityspstring != "") { + $securityspstring .= "; "; + } + $sourcestring = ""; + foreach ($sourcekeys as $key => $source) { + $directivetype = $directivesarray[$directive]["data-directivetype"]; + $sourcetype = $sourcesarray[$directivetype][$source]["data-sourcetype"]; + if (isset($sourcetype) && $sourcetype == "quoted") { + $sourcestring .= " '".$source."'"; + } elseif ($directivetype != "none") { + $sourcestring .= " ".$source; + } + } + $securityspstring .= $directive . $sourcestring; + } + $res = dolibarr_set_const($db, 'MAIN_SECURITY_FORCECSP', $securityspstring, 'chaine', 0, '', $conf->entity); + if ($res <= 0) { + $error++; + } + } + + if (!$error) { + $db->commit(); + setEventMessages($langs->trans("MainSecurityPolicySucesfullyAdded"), null, 'mesgs'); + } else { + $db->rollback(); + setEventMessages($langs->trans("MainErrorAddingSecurityPolicy"), null, 'errors'); + } + header("Location: ".$_SERVER["PHP_SELF"]); + exit(); +} elseif ($action == "updateform") { + $db->begin(); + $res1 = $res2 = $res3 = $res4 = 0; + $securityrp = GETPOST('MAIN_SECURITY_FORCERP', 'alpha'); + $securitysts = GETPOST('MAIN_SECURITY_FORCESTS', 'alpha'); + $securitypp = GETPOST('MAIN_SECURITY_FORCEPP', 'alpha'); + $securitysp = GETPOST('MAIN_SECURITY_FORCECSP', 'alpha'); + $securitycspro = GETPOST('MAIN_SECURITY_FORCECSPRO', 'alpha'); + + $res1 = dolibarr_set_const($db, 'MAIN_SECURITY_FORCERP', $securityrp, 'chaine', 0, '', $conf->entity); + $res2 = dolibarr_set_const($db, 'MAIN_SECURITY_FORCESTS', $securitysts, 'chaine', 0, '', $conf->entity); + $res3 = dolibarr_set_const($db, 'MAIN_SECURITY_FORCEPP', $securitypp, 'chaine', 0, '', $conf->entity); + $res4 = dolibarr_set_const($db, 'MAIN_SECURITY_FORCECSP', $securitysp, 'chaine', 0, '', $conf->entity); + $res5 = dolibarr_set_const($db, 'MAIN_SECURITY_FORCECSPRO', $securitycspro, 'chaine', 0, '', $conf->entity); + + if ($res1 >= 0 && $res2 >= 0 && $res3 >= 0 && $res4 >= 0 && $res5 >= 0) { + $db->commit(); + setEventMessages($langs->trans("Saved"), null, 'mesgs'); + } else { + $db->rollback(); + setEventMessages($langs->trans("ErrorSavingChanges"), null, 'errors'); + } + $action = ''; + $forceCSP = getDolGlobalString("MAIN_SECURITY_FORCECSP"); +} + + + +/* + * View + */ + +$form = new Form($db); + +$wikihelp = 'EN:Setup_Security|FR:Paramétrage_Sécurité|ES:Configuración_Seguridad'; +llxHeader('', $langs->trans("MainHttpSecurityHeaders"), $wikihelp, '', 0, 0, '', '', '', 'mod-admin page-security_other'); + +print load_fiche_titre($langs->trans("SecuritySetup"), '', 'title_setup'); +$head = security_prepare_head(); + +print dol_get_fiche_head($head, 'headers_http', '', -1); + +print '
'; + +print ''.$langs->trans("HTTPHeaderEditor").'. '.$langs->trans("ReservedToAdvancedUsers").'.

'; + +print '
'; +print ''; +print ''; +print ''; +print ''."\n"; +print ''; + +print ''; +print ''; +print ''; + +// Force RP +print ''; +print ''; +print ''; +print ''; +// Force STS +print ''; +print ''; +print ''; +print ''; +// Force PP +print ''; +print ''; +print ''; +print ''; + +$examplecsprule = "frame-ancestors 'self'; img-src * data:; font-src *; default-src 'self' 'unsafe-inline' 'unsafe-eval' *.paypal.com *.stripe.com *.google.com *.googleapis.com *.google-analytics.com *.googletagmanager.com;"; + +// Force CSP - Content Security Policy +print ''; +print ''; +print ''; +print ''; + +// Force CSPRO +if (getDolGlobalString("MAIN_SECURITY_FORCECSPRO")) { + print ''; + print ''; + print ''; + print ''; +} + +print '
'.$langs->trans("HTTPHeader").'
'.$form->textwithpicto($langs->trans('MainSecurityForceRP'), 'HTTP Header Referer-Policy

'.$langs->trans("Recommended").':
"strict-origin-when-cross-origin" '.$langs->trans("or").' "same-origin"=more secured"', 1, 'help', 'valignmiddle', 0, 3, 'MAIN_SECURITY_FORCERP').'
'.$form->textwithpicto($langs->trans('MainSecurityForceSTS'), 'HTTP Header Strict-Transport-Security

'.$langs->trans("Example").':
"max-age=31536000; includeSubDomains"', 1, 'help', 'valignmiddle', 0, 3, 'MAIN_SECURITY_FORCESTS').'
'.$form->textwithpicto($langs->trans('MainSecurityForcePP'), 'HTTP Header Permissions-Policy

'.$langs->trans("Example").':
"camera=(), microphone=(), geolocation=*"', 1, 'help', 'valignmiddle', 0, 3, 'MAIN_SECURITY_FORCEPP').'
'.$form->textwithpicto($langs->trans('MainContentSecurityPolicy'), 'HTTP Header Content-Security-Policy

'.$langs->trans("Example").":
".$examplecsprule, 1, 'help', 'valignmiddle', 0, 3, 'MAIN_SECURITY_FORCECSP').'
'; + +print '
'; + +print ' '.img_picto('', 'add').'
'; + +print ''; + +print ''; + +if (!empty($forceCSP)) { + // Content Security Policy list of selected rules + print '
'; + print '
'; + print img_picto('', 'graph', 'class="pictofixedwidth"').$langs->trans("HierarchicView").'
'; + print '
    '; + foreach ($forceCSPArr as $directive => $sources) { + print '
  • '; + if (in_array($directive, array_keys($selectarrayCSPDirectives))) { + print ''.$directive.''; + } else { + print $form->textwithpicto($directive, $langs->trans("UnknowContentSecurityPolicyDirective"), 1, 'warning'); + } + if (!empty($sources)) { + print '
      '; + foreach ($sources as $key => $source) { + print '
    • '.$source.' '.img_delete().'
    • '; + } + print '
    '; + } else { + print ' '.img_delete().''; + } + print '
  • '; + } + print '
'; + print '
'; +} +print '
'; + +print '
'.$form->textwithpicto($langs->trans('MainSecurityForceCSPRO'), 'HTTP Header Content-Security-Policy-Report-Only

'.$langs->trans("Example").":
".$examplecsprule, 1, 'help', 'valignmiddle', 0, 3, 'MAIN_SECURITY_FORCECSPRO').'
'; +print '
'; + + +print '
'; + +print ''; +print ''; + +print '
'; + + +print ''; + +print dol_get_fiche_end(); +print ''; + +// End of page +llxFooter(); +$db->close(); diff --git a/htdocs/core/lib/admin.lib.php b/htdocs/core/lib/admin.lib.php index 37b93ecc86e..400d671f61c 100644 --- a/htdocs/core/lib/admin.lib.php +++ b/htdocs/core/lib/admin.lib.php @@ -937,6 +937,11 @@ function security_prepare_head() $h++; } + $head[$h][0] = DOL_URL_ROOT."/admin/security_headers_http.php"; + $head[$h][1] = $langs->trans("MainHttpSecurityHeaders"); + $head[$h][2] = 'headers_http'; + $h++; + return $head; } @@ -2197,3 +2202,154 @@ function email_admin_prepare_head() return $head; } + +/** + * Prepare array of directives for HTTP headers + * + * @return array> Array of directives + */ +function GetContentPolicyDirectives() +{ + return array( + // Fetch directives + "child-src" => array("label" => "child-src", "data-directivetype" => "fetch"), + "connect-src" => array("label" => "connect-src", "data-directivetype" => "fetch"), + "default-src" => array("label" => "default-src", "data-directivetype" => "fetch"), + "fenced-frame-src" => array("label" => "fenced-frame-src", "data-directivetype" => "fetch"), + "font-src" => array("label" => "font-src", "data-directivetype" => "fetch"), + "frame-src" => array("label" => "frame-src", "data-directivetype" => "fetch"), + "img-src" => array("label" => "img-src", "data-directivetype" => "fetch"), + "manifest-src" => array("label" => "manifest-src", "data-directivetype" => "fetch"), + "media-src" => array("label" => "media-src", "data-directivetype" => "fetch"), + "object-src" => array("label" => "object-src", "data-directivetype" => "fetch"), + "prefetch-src" => array("label" => "prefetch-src", "data-directivetype" => "fetch"), + "script-src" => array("label" => "script-src", "data-directivetype" => "fetch"), + "script-src-elem" => array("label" => "script-src-elem", "data-directivetype" => "fetch"), + "script-src-attr" => array("label" => "script-src-attr", "data-directivetype" => "fetch"), + "style-src" => array("label" => "style-src","data-directivetype" => "fetch"), + "style-src-elem" => array("label" => "style-src-elem", "data-directivetype" => "fetch"), + "style-src-attr" => array("label" => "style-src-attr", "data-directivetype" => "fetch"), + "worker-src" => array("label" => "worker-src", "data-directivetype" => "fetch"), + // Document directives + "base-uri" => array("label" => "base-uri", "data-directivetype" => "document"), + "sandbox" => array("label" => "sandbox", "data-directivetype" => "document"), + // Navigation directives + "form-action" => array("label" => "form-action", "data-directivetype" => "navigation"), + "frame-ancestors" => array("label" => "frame-ancestors", "data-directivetype" => "navigation"), + // Reporting directives + "report-to" => array("label" => "report-to", "data-directivetype" => "reporting"), + // Other directives + "require-trusted-types-for" => array("label" => "require-trusted-types-for", "data-directivetype" => "require-trusted-types-for"), + "trusted-types" => array("label" => "trusted-types", "data-directivetype" => "trusted-types"), + "upgrade-insecure-requests" => array("label" => "upgrade-insecure-requests", "data-directivetype" => "none"), + ); +} + +/** + * Prepare array of sources for HTTP headers + * + * @return array>> Array of sources + */ +function GetContentPolicySources() +{ + return array( + // Fetch directives + "fetch" => array( + "*" => array("label" => "*", "data-sourcetype" => "select"), + "data" => array("label" => "data:", "data-sourcetype" => "data"), + "self" => array("label" => "self", "data-sourcetype" => "quoted"), + "unsafe-eval" => array("label" => "unsafe-eval", "data-sourcetype" => "quoted"), + "wasm-unsafe-eval" => array("label" => "wasm-unsafe-eval", "data-sourcetype" => "quoted"), + "unsafe-inline" => array("label" => "unsafe-inline", "data-sourcetype" => "quoted"), + "unsafe-hashes" => array("label" => "unsafe-hashes", "data-sourcetype" => "quoted"), + "inline-speculation-rules" => array("label" => "inline-speculation-rules", "data-sourcetype" => "quoted"), + "strict-dynamic" => array("label" => "strict-dynamic", "data-sourcetype" => "quoted"), + "report-sample" => array("label" => "report-sample", "data-sourcetype" => "quoted"), + "host-source" => array("label" => "host-source (*.mydomain.com)", "data-sourcetype" => "input"), + "scheme-source" => array("label" => "scheme-source", "data-sourcetype" => "input"), + ), + // Document directives + "document" => array( + "none" => array("label" => "self", "data-sourcetype" => "quoted"), + "self" => array("label" => "self", "data-sourcetype" => "quoted"), + "host-source" => array("label" => "host-source (*.mydomain.com)", "data-sourcetype" => "input"), + "scheme-source" => array("label" => "scheme-source (*.mydomain.com)", "data-sourcetype" => "input"), + ), + // Navigation directives + "navigation" => array( + "none" => array("label" => "self", "data-sourcetype" => "quoted"), + "self" => array("label" => "self", "data-sourcetype" => "quoted"), + "host-source" => array("label" => "host-source (*.mydomain.com)", "data-sourcetype" => "input"), + "scheme-source" => array("label" => "scheme-source", "data-sourcetype" => "input"), + ), + // Reporting directives + "reporting" => array( + "report-to" => array("label" => "report-to", "data-sourcetype" => "input"), + ), + // Other directives + "require-trusted-types-for" => array( + "script" => array("label" => "script", "data-sourcetype" => "select"), + ), + "trusted-types" => array( + "policyName" => array("label" => "policyName", "data-sourcetype" => "input"), + "none" => array("label" => "none", "data-sourcetype" => "quoted"), + "allow-duplicates" => array("label" => "allow-duplicates", "data-sourcetype" => "quoted"), + ), + ); +} + +/** + * Transform a Content Security Policy to an array + * + * @param string $forceCSP Content security policy string + * @return array|string>> Array of sources + */ +function GetContentPolicyToArray($forceCSP) +{ + $forceCSPArr = array(); + $sourceCSPArr = GetContentPolicySources(); + $sourceCSPArrflatten = array(); + + // We remove a level for sources array + foreach ($sourceCSPArr as $key => $arr) { + $sourceCSPArrflatten = array_merge($sourceCSPArrflatten, array_keys($arr)); + } + // Gerer le problème avec data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D qui est split + problème avec button ajouter + $forceCSP = preg_replace('/;base64,/', "__semicolumnbase64__", $forceCSP); + $securitypolicies = explode(";", $forceCSP); + + // Loop on each security policy to create an array + foreach ($securitypolicies as $key => $securitypolicy) { + if ($securitypolicy == "") { + continue; + } + $securitypolicy = preg_replace('/__semicolumnbase64__/', ";base64,", $securitypolicy); + $securitypolicyarr = explode(" ", $securitypolicy); + $directive = array_shift($securitypolicyarr); + // Remove unwanted spaces + while ($directive == "") { + $directive = array_shift($securitypolicyarr); + } + if (empty($directive)) { + continue; + } + $sources = $securitypolicyarr; + if (empty($sources)) { + $forceCSPArr[$directive] = array(); + } else { + //Loop on each sources to add to the right directive array key + foreach ($sources as $key2 => $source) { + $source = str_replace("'", "", $source); + if (empty($source)) { + continue; + } + if (empty($forceCSPArr[$directive])) { + $forceCSPArr[$directive] = array($source); + } else { + $forceCSPArr[$directive][] = $source; + } + } + } + } + return $forceCSPArr; +} diff --git a/htdocs/langs/en_US/admin.lang b/htdocs/langs/en_US/admin.lang index ee7d8319b16..a9bf536f837 100644 --- a/htdocs/langs/en_US/admin.lang +++ b/htdocs/langs/en_US/admin.lang @@ -2697,3 +2697,13 @@ PDF_INVOICE_SHOW_BALANCE_SUMMARY= Show customer's previous and new balance SecurityModuleDeploymentSuccess=An external module has been deployed: %s SecurityModuleDeploymentError=A deployment of an external module has been tried and failed: %s DownloadOfModuleFileDisallowed=Download of module files not allowed (param $dolibarr_allow_download_external_modules must be set in conf.php) +MainHttpSecurityHeaders=Main HTTP security headers +MainSecurityForceRP=Main HTTP "Referer-Policy" +MainSecurityForceSTS=Main HTTP "Strict-Transport-Security" +MainSecurityForcePP=Main HTTP "Permissions Policy" +MainContentSecurityPolicy=Main HTTP "Content-Security-Policy" +MainSecurityForceCSPRO=Main HTTP "Content-Securit-Policy-Report-Only" +MainSecurityPolicySucesfullyRemoved=Content Security Policy successfully removed +MainErrorRemovingSecurityPolicy=An error occurred when trying to remove a Content Security Policy +MainSecurityPolicySucesfullyAdded=Content Security Policy successfully added +MainErrorAddingSecurityPolicy=An error occurred when trying to add a Content Security Policy