* @package api * @subpackage header * @access public * @version $Id$ */ namespace EGroupware\Api\Header; use EGroupware\Api; /** * Content Security Policy headers */ class ContentSecurityPolicy { /** * Additional attributes or urls for CSP beside always added 'self' for everything not 'none' * * - "script-src 'self' 'unsafe-eval'" allows only self and eval, but forbids inline scripts, onchange, etc * - "connect-src 'self'" allows ajax requests only to self * - "style-src 'self' 'unsafe-inline'" allows only self and inline style, which we need * - "frame-src 'self' manual.egroupware.org" allows frame and iframe content only for self or manual.egroupware.org * - "manifest-src 'self'" * - "'"frame-ancestors 'self'" does not allow to frame (embed in frameset) other then self / clickjacking protection * - "media-src 'self' data:" * - "img-src 'self' data: https:" * - "default-src 'none'" disallows all not explicitly set sources * * @var array */ private static $sources = array( // our dhtmlxcommon version (not the current) uses eval, 'script-src' => array("'unsafe-eval'"), // sidebox javascript links, et2_widget_date / jQueryUI datepicker, maybe more 'style-src' => array("'unsafe-inline'"), // eTemplate styles and custom framework colors 'connect-src' => null, // NOT array(), to call the hook 'frame-src' => null, // NOT array(), to call the hook 'manifest-src'=> ["'self'"], 'frame-ancestors' => ["'self'"], // does not allow to frame (embed in frameset) other then self / clickjacking protection 'media-src' => ["data:"], 'img-src' => ["data:", "https:", "blob:"], 'default-src' => ["'none'"], // disallows all not explicit set sources! ); /** * Add Content-Security-Policy sources * * Calling this method with an empty array for frame-src or connect-src causes the hook to NOT run and just set 'self'! * * @param string $source valid CSP source types like 'script-src', 'style-src', 'connect-src', 'frame-src', ... * @param string|array $attrs 'unsafe-eval', 'unsafe-inline' (without quotes!), full URLs or protocols (incl. colon!) * 'none' removes all other attributes, even ones set later! * @param bool $reset =false true: remove existing default or hook attributes */ public static function add($source, $attrs, $reset=false) { if ($reset) { self::$sources[$source] = []; } elseif (!isset(self::$sources[$source])) { // set frame-src attrs of API and apps via hook if (in_array($source, ['frame-src', 'connect-src']) && $attrs !== []) { // for regular (non login) pages, call hook allowing apps to add additional frame- and connect-src if (basename($_SERVER['PHP_SELF']) !== 'login.php' && // no permission / user-run-rights check for connect-src ($app_additional = Api\Hooks::process('csp-'.$source, [], $source === 'connect-src'))) { foreach($app_additional as $app => $additional) { if ($additional) $attrs = array_unique(array_merge((array)$attrs, $additional)); } } } self::$sources[$source] = []; } foreach((array)$attrs as $attr) { if (in_array($attr, array('none', 'self', 'unsafe-eval', 'unsafe-inline'))) { $attr = "'$attr'"; // automatic add quotes } // only add scheme and host, not path elseif ($source !== 'report-uri' && ($parsed=parse_url($attr)) && !empty($parsed['scheme']) && !empty($parsed['path'])) { $attr = $parsed['scheme'].'://'.$parsed['host'].(!empty($parsed['port']) ? ':'.$parsed['port'] : ''); } if (!in_array($attr, self::$sources[$source])) { self::$sources[$source][] = $attr; //error_log(__METHOD__."() setting CSP script-src $attr ".function_backtrace()); } } } /** * Add a nonce to a given source * * @param string $source * @return string * @throws \Exception */ public static function addNonce($source='script-src') { static $nonce=null; if (!isset($nonce)) { $nonce = base64_encode(random_bytes(16)); self::add($source, "'nonce-$nonce'"); } return $nonce; } /** * Set Content-Security-Policy attributes for script-src: 'unsafe-eval' and/or 'unsafe-inline' * * Old pre-et2 apps might need to call Api\Headers::script_src_attrs(array('unsafe-eval','unsafe-inline')) * * EGroupware itself currently still requires 'unsafe-eval'! * * @param string|array $set 'unsafe-eval', 'unsafe-inline' (without quotes!), full URLs or protocols (incl. colon!) */ public static function add_script_src($set=null) { self::add('script-src', $set); } /** * Set Content-Security-Policy attributes for style-src: 'unsafe-inline' * * EGroupware itself currently still requires 'unsafe-inline'! * * @param string|array $set 'unsafe-eval', 'unsafe-inline' (without quotes!), full URLs or protocols (incl. colon!) */ public static function add_style_src($set=null) { self::add('style-src', $set); } /** * Set Content-Security-Policy attributes for connect-src: * * Calling this method with an empty array for caused the hook to NOT run and just set 'self'! * * @param string|array $set 'unsafe-eval', 'unsafe-inline' (without quotes!), full URLs or protocols (incl. colon!) */ public static function add_connect_src($set=null) { self::add('connect-src', $set); } /** * Set/get Content-Security-Policy attributes for frame-src: * * Calling this method with an empty array for caused the hook to NOT run and just set 'self'! * * @param string|array $set 'unsafe-eval', 'unsafe-inline' (without quotes!), full URLs or protocols (incl. colon!) */ public static function add_frame_src($set=null) { self::add('frame-src', $set); } /** * Send Content-Security-Policy header * * @link http://content-security-policy.com/ */ public static function send() { self::add('connect-src', null); // set defaults for connect-src (no run rights checked) self::add('frame-src', null); // set defaults for frame-src // force default-src 'none' self::$sources['default-src'] = ["'none'"]; $policies = array(); foreach (self::$sources as $source => $urls) { // for 'none' remove source, as we use "default-src 'none'" if (in_array("'none'", $urls)) { if ($source !== 'default-src') continue; } // automatic add 'self', if not 'none' elseif (!in_array("'self'", $urls)) { array_unshift($urls, "'self'"); } $policies[] = "$source " . implode(' ', $urls); } self::header(implode('; ', $policies)); } /** * Send a CSP header with given policy * * @param {string} $csp */ public static function header($csp) { $user_agent = UserAgent::type(); $version = UserAgent::version(); // recommendation is to not send regular AND deprecated headers together, as they can cause unexpected behavior if ($user_agent === 'chrome' && $version < 25 || $user_agent === 'safari' && $version < 7) { header("X-Webkit-CSP: $csp"); // Chrome: <= 24, Safari incl. iOS } elseif ($user_agent === 'firefox' && $version < 23 || $user_agent === 'msie') // Edge is reported as 'edge'! { header("X-Content-Security-Policy: $csp"); } else { header("Content-Security-Policy: $csp"); } } }