diff --git a/config/defaults.inc.php b/config/defaults.inc.php index 35901ab698e..760765c5522 100644 --- a/config/defaults.inc.php +++ b/config/defaults.inc.php @@ -1563,3 +1563,14 @@ // 0 - Reply-All always // 1 - Reply-List if mailing list is detected $config['reply_all_mode'] = 0; + +// The Content-Security-Policy to use if no remote objects are allowed to +// be loaded. If you use plugins you might need to extend this. +// Only change this if you know what you're doing! You can break the whole +// application with changes to this setting! +// To disable completely set the value to `false`; +$config['content_security_policy'] = "default-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"; + +// Additions to the Content-Security-Policy to use if remote objects *are* +// allowed to be loaded. +$config['content_security_policy_add_allow_remote'] = 'img-src *; media-src *; font-src: *; frame-src: *'; diff --git a/program/include/rcmail_output_html.php b/program/include/rcmail_output_html.php index 75e9fbf0e17..1de462b33f8 100644 --- a/program/include/rcmail_output_html.php +++ b/program/include/rcmail_output_html.php @@ -728,6 +728,8 @@ public function page_headers() $this->header('X-Frame-Options: sameorigin', true); } } + + $this->add_csp_header(); } /** @@ -2717,4 +2719,41 @@ protected function get_template_logo($type = null, $match = null) return $template_logo; } + + /** + * Add the Content-Security-Policy to the HTTP response headers (unless it + * is disabled). + */ + protected function add_csp_header(): void + { + $csp = $this->get_csp_value('content_security_policy'); + if ($csp !== false) { + $csp_parts = [$csp]; + if (isset($this->env['safemode']) && $this->env['safemode'] === true) { + $csp_allow_remote = $this->get_csp_value('content_security_policy_add_allow_remote'); + if ($csp_allow_remote !== false) { + $csp_parts[] = $csp_allow_remote; + } + } + $this->header('Content-Security-Policy: ' . implode('; ', $csp_parts)); + } + } + + /** + * Get a CSP-related value from the config, stripped by surrounding + * whitespace and semicolons (and NUL byte, because it's included in the + * default second argument to trim(), too). + * + * @param $name string The key of the wanted config value + * + * @return string|false + */ + protected function get_csp_value($name) + { + $value = $this->app->config->get($name); + if (is_string($value)) { + return trim($value, "; \n\r\t\v\x00"); + } + return false; + } } diff --git a/program/lib/Roundcube/rcube_config.php b/program/lib/Roundcube/rcube_config.php index 60833754254..2b635212187 100644 --- a/program/lib/Roundcube/rcube_config.php +++ b/program/lib/Roundcube/rcube_config.php @@ -24,6 +24,11 @@ class rcube_config { public const DEFAULT_SKIN = 'elastic'; + public const DEFAULTS = [ + 'content_security_policy' => "default-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'", + 'content_security_policy_add_allow_remote' => 'img-src *; media-src *; font-src: *; frame-src: *', + ]; + /** @var string A skin configured in the config file (before being replaced by a user preference) */ public $system_skin = 'elastic'; @@ -376,8 +381,12 @@ public function get($name, $def = null) { if (isset($this->prop[$name])) { $result = $this->prop[$name]; - } else { + } elseif (isset($def)) { $result = $def; + } elseif (isset(self::DEFAULTS[$name])) { + $result = self::DEFAULTS[$name]; + } else { + $result = null; } $result = $this->getenv_default('ROUNDCUBE_' . strtoupper($name), $result); diff --git a/tests/Framework/ConfigTest.php b/tests/Framework/ConfigTest.php index d3da1e8b8fa..ca551507709 100644 --- a/tests/Framework/ConfigTest.php +++ b/tests/Framework/ConfigTest.php @@ -96,4 +96,22 @@ public function test_parse_env() $this->assertSame([1], invokeMethod($object, 'parse_env', ['[1]', 'array'])); $this->assertSame(['test' => 1], (array) invokeMethod($object, 'parse_env', ['{"test":1}', 'object'])); } + + // Test if values in defaults.inc.php and values in rcube_config::DEFAULTS match + public function test_defaults_values(): void + { + // Initialize the variable to avoid warnings. + $config = null; + // Load the values from defaults.inc.php manually (we don't want to + // test the whole loading mechanics of `rcube_config` here). + ob_start(); + require realpath(RCUBE_INSTALL_PATH . 'config/defaults.inc.php'); + ob_end_clean(); + + $this->assertIsArray($config); // @phpstan-ignore-line + + foreach (\rcube_config::DEFAULTS as $name => $hardcoded_value) { + $this->assertSame($hardcoded_value, $config[$name], "The value for '{$name}' in defaults.inc.php does not match the hardcoded default in rcube_config!"); + } + } } diff --git a/tests/Rcmail/OutputHtmlTest.php b/tests/Rcmail/OutputHtmlTest.php index 45c359b288b..8fc9b524789 100644 --- a/tests/Rcmail/OutputHtmlTest.php +++ b/tests/Rcmail/OutputHtmlTest.php @@ -5,6 +5,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function Roundcube\Tests\invokeMethod; + /** * Test class to test rcmail_output_html class */ @@ -429,4 +431,66 @@ public function test_charset_selector() $this->assertTrue(strpos($result, '