From e42fda893c62286d2948b0f18f4dd2fd846de098 Mon Sep 17 00:00:00 2001 From: Zhey Date: Thu, 26 Mar 2026 12:00:08 +0100 Subject: [PATCH] Add CSP controls for image and CSS sources --- .env.example.complete | 12 +++++++ app/Config/app.php | 10 ++++++ app/Util/CspService.php | 65 ++++++++++++++++++++++++++++++++++++ dev/docs/development.md | 42 +++++++++++++++++++++++ readme.md | 1 + tests/SecurityHeaderTest.php | 36 ++++++++++++++++++++ 6 files changed, 166 insertions(+) diff --git a/.env.example.complete b/.env.example.complete index ebebaf9e3e8..6a7f9db6529 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -395,6 +395,18 @@ ALLOWED_IFRAME_HOSTS=null # Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com" +# A list of sources/hostnames that can be loaded as CSS styles within BookStack. +# Space separated if multiple. BookStack host domain is auto-inferred. +# Defaults to a permissive set if not provided. +# Example: ALLOWED_CSS_SOURCES="https://fonts.googleapis.com" +ALLOWED_CSS_SOURCES=null + +# A list of sources/hostnames that can be loaded as image content within BookStack. +# Space separated if multiple. BookStack host domain is auto-inferred. +# Defaults to a permissive set if not provided. +# Example: ALLOWED_IMAGE_SOURCES="https://images.example.com data:" +ALLOWED_IMAGE_SOURCES=null + # A list of the sources/hostnames that can be reached by application SSR calls. # This is used wherever users can provide URLs/hosts in-platform, like for webhooks. # Host-specific functionality (usually controlled via other options) like auth diff --git a/app/Config/app.php b/app/Config/app.php index a476fdfea1c..5536e9abdd4 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -72,6 +72,16 @@ // Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. 'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'), + // A list of sources/hostnames that can be loaded as CSS styles within BookStack. + // Space separated if multiple. BookStack host domain is auto-inferred. + // If not set, a permissive default set is used to reduce potential breakage. + 'css_sources' => env('ALLOWED_CSS_SOURCES', null), + + // A list of sources/hostnames that can be loaded as image content within BookStack. + // Space separated if multiple. BookStack host domain is auto-inferred. + // If not set, a permissive default set is used to reduce potential breakage. + 'image_sources' => env('ALLOWED_IMAGE_SOURCES', null), + // A list of the sources/hostnames that can be reached by application SSR calls. // This is used wherever users can provide URLs/hosts in-platform, like for webhooks. // Host-specific functionality (usually controlled via other options) like auth diff --git a/app/Util/CspService.php b/app/Util/CspService.php index 466acb49148..a0e1faadf95 100644 --- a/app/Util/CspService.php +++ b/app/Util/CspService.php @@ -30,6 +30,8 @@ public function getCspHeader(): string $this->getFrameAncestors(), $this->getFrameSrc(), $this->getScriptSrc(), + $this->getStyleSrc(), + $this->getImgSrc(), $this->getObjectSrc(), $this->getBaseUri(), ]; @@ -45,6 +47,8 @@ public function getCspMetaTagValue(): string $headers = [ $this->getFrameSrc(), $this->getScriptSrc(), + $this->getStyleSrc(), + $this->getImgSrc(), $this->getObjectSrc(), $this->getBaseUri(), ]; @@ -115,6 +119,22 @@ protected function getObjectSrc(): string return "object-src 'self'"; } + /** + * Creates CSP 'style-src' rule to restrict where styles can be loaded from. + */ + protected function getStyleSrc(): string + { + return 'style-src ' . implode(' ', $this->getAllowedStyleSources()); + } + + /** + * Creates CSP 'img-src' rule to restrict where images can be loaded from. + */ + protected function getImgSrc(): string + { + return 'img-src ' . implode(' ', $this->getAllowedImageSources()); + } + /** * Creates CSP 'base-uri' rule to restrict what base tags can be set on * the page to prevent manipulation of relative links. @@ -144,6 +164,51 @@ protected function getAllowedIframeSources(): array return array_filter($sources); } + /** + * Get allowed style sources for the style-src directive. + */ + protected function getAllowedStyleSources(): array + { + $configured = config('app.css_sources'); + + if (is_string($configured)) { + $sources = array_filter(explode(' ', $configured)); + array_unshift($sources, "'self'"); + + return array_values(array_unique($sources)); + } + + return [ + "'self'", + "'unsafe-inline'", + 'http:', + 'https:', + ]; + } + + /** + * Get allowed image sources for the img-src directive. + */ + protected function getAllowedImageSources(): array + { + $configured = config('app.image_sources'); + + if (is_string($configured)) { + $sources = array_filter(explode(' ', $configured)); + array_unshift($sources, "'self'"); + + return array_values(array_unique($sources)); + } + + return [ + "'self'", + 'data:', + 'blob:', + 'http:', + 'https:', + ]; + } + /** * Extract the host name of the configured drawio URL for use in CSP. * Returns empty string if not in use. diff --git a/dev/docs/development.md b/dev/docs/development.md index 2c73a0256c5..16f168f9cda 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -31,6 +31,48 @@ BookStack has a large suite of PHP tests to cover application functionality. We For details about setting-up, running and writing tests please see the [php-testing.md document](php-testing.md). +## Content Security Policy Controls + +BookStack enforces a Content Security Policy (CSP) response header to reduce risk from injected content and untrusted embeds. + +For backward compatibility, image and CSS controls are intentionally permissive by default, but can be tightened via environment options. + +### Related Environment Options + +These values are defined in `.env.example.complete`: + +- `ALLOWED_CSS_SOURCES` + - Controls allowed `style-src` sources. + - Defaults to a permissive fallback if unset. +- `ALLOWED_IMAGE_SOURCES` + - Controls allowed `img-src` sources. + - Defaults to a permissive fallback if unset. + +Values should be space-separated source expressions. + +### Example Configurations + +Allow Google Fonts CSS and local styles only: + +```bash +ALLOWED_CSS_SOURCES="https://fonts.googleapis.com" +``` + +Allow local images, embedded data images, and a dedicated image CDN: + +```bash +ALLOWED_IMAGE_SOURCES="data: https://images.example.com" +``` + +### Tightening Guidance + +When hardening a deployment: + +1. Start with defaults to avoid unexpected breakage. +2. Set explicit `ALLOWED_CSS_SOURCES` and `ALLOWED_IMAGE_SOURCES` values for the domains you actually use. +3. Test key workflows (editor, page display, theme assets, external embeds) and browser console CSP warnings. +4. Remove unnecessary protocols and hosts over time. + ## Code Standards We use tools to manage code standards and formatting within the project. If submitting a PR, formatting as per our project standards would help for clarity but don't worry too much about using/understanding these tools as we can always address issues at a later stage when they're picked up by our automated tools. diff --git a/readme.md b/readme.md index 00eb135046e..672cff55a41 100644 --- a/readme.md +++ b/readme.md @@ -102,6 +102,7 @@ Big thanks to these companies for supporting the project. ## 🛠️ Development & Testing Please see our [development docs](dev/docs/development.md) for full details regarding work on the BookStack source code. +For details on Content Security Policy controls (including image and CSS source options), see the **Content Security Policy Controls** section in the [development docs](dev/docs/development.md). If you're just looking to customize or extend your own BookStack instance, take a look at our [Hacking BookStack documentation page](https://www.bookstackapp.com/docs/admin/hacking-bookstack/) for details on various options to achieve this without altering the BookStack source code. diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php index 3f4b7d193ce..126a85f238c 100644 --- a/tests/SecurityHeaderTest.php +++ b/tests/SecurityHeaderTest.php @@ -151,6 +151,42 @@ public function test_frame_src_csp_header_drawio_host_includes_port_if_existing( $this->assertEquals('frame-src \'self\' https://example.com https://diagrams.example.com:8080', $scriptHeader); } + public function test_style_src_csp_header_set_to_permissive_defaults_when_not_configured() + { + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'style-src'); + + $this->assertEquals("style-src 'self' 'unsafe-inline' http: https:", $header); + } + + public function test_style_src_csp_header_can_be_overridden_by_config() + { + config()->set('app.css_sources', 'https://fonts.example.com'); + + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'style-src'); + + $this->assertEquals("style-src 'self' https://fonts.example.com", $header); + } + + public function test_img_src_csp_header_set_to_permissive_defaults_when_not_configured() + { + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'img-src'); + + $this->assertEquals("img-src 'self' data: blob: http: https:", $header); + } + + public function test_img_src_csp_header_can_be_overridden_by_config() + { + config()->set('app.image_sources', 'https://images.example.com data:'); + + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'img-src'); + + $this->assertEquals("img-src 'self' https://images.example.com data:", $header); + } + public function test_cache_control_headers_are_set_on_responses() { // Public access