diff --git a/.env.example.complete b/.env.example.complete
index 19643a49f..e3dbdb857 100644
--- a/.env.example.complete
+++ b/.env.example.complete
@@ -273,6 +273,12 @@ ALLOW_CONTENT_SCRIPTS=false
# Contents of the robots.txt file can be overridden, making this option obsolete.
ALLOW_ROBOTS=null
+# A list of hosts that BookStack can be iframed within.
+# Space separated if multiple. BookStack host domain is auto-inferred.
+# For Example: ALLOWED_IFRAME_HOSTS="https://example.com https://a.example.com"
+# Setting this option will also auto-adjust cookies to be SameSite=None.
+ALLOWED_IFRAME_HOSTS=null
+
# The default and maximum item-counts for listing API requests.
API_DEFAULT_ITEM_COUNT=100
API_MAX_ITEM_COUNT=500
diff --git a/app/Config/app.php b/app/Config/app.php
index 7297048b4..a4367d484 100755
--- a/app/Config/app.php
+++ b/app/Config/app.php
@@ -52,6 +52,10 @@ return [
// and used by BookStack in URL generation.
'url' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''),
+ // A list of hosts that BookStack can be iframed within.
+ // Space separated if multiple. BookStack host domain is auto-inferred.
+ 'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
+
// Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'),
diff --git a/app/Config/session.php b/app/Config/session.php
index 37f1627bb..571836bd2 100644
--- a/app/Config/session.php
+++ b/app/Config/session.php
@@ -1,5 +1,7 @@
env('SESSION_SECURE_COOKIE', false),
+ 'secure' => env('SESSION_SECURE_COOKIE', null)
+ ?? Str::startsWith(env('APP_URL'), 'https:'),
// HTTP Access Only
// Setting this value to true will prevent JavaScript from accessing the
@@ -80,6 +83,6 @@ return [
// This option determines how your cookies behave when cross-site requests
// take place, and can be used to mitigate CSRF attacks. By default, we
// do not enable this as other CSRF protection services are in place.
- // Options: lax, strict
- 'same_site' => null,
+ // Options: lax, strict, none
+ 'same_site' => 'lax',
];
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index a0c45ea89..532942f23 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -22,6 +22,7 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
+ \BookStack\Http\Middleware\ControlIframeSecurity::class,
\BookStack\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
diff --git a/app/Http/Middleware/ControlIframeSecurity.php b/app/Http/Middleware/ControlIframeSecurity.php
new file mode 100644
index 000000000..cc8034413
--- /dev/null
+++ b/app/Http/Middleware/ControlIframeSecurity.php
@@ -0,0 +1,36 @@
+filter();
+ if ($iframeHosts->count() > 0) {
+ config()->set('session.same_site', 'none');
+ }
+
+ $iframeHosts->prepend("'self'");
+
+ $response = $next($request);
+ $cspValue = 'frame-ancestors ' . $iframeHosts->join(' ');
+ $response->headers->set('Content-Security-Policy', $cspValue);
+ return $response;
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
index ad7c6f43a..8d69a5fdd 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -24,6 +24,8 @@
+
+
@@ -35,6 +37,7 @@
+
@@ -47,7 +50,6 @@
-
diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php
new file mode 100644
index 000000000..db095ff70
--- /dev/null
+++ b/tests/SecurityHeaderTest.php
@@ -0,0 +1,71 @@
+get("/");
+ foreach ($resp->headers->getCookies() as $cookie) {
+ $this->assertEquals("lax", $cookie->getSameSite());
+ }
+ }
+
+ public function test_cookies_samesite_none_when_iframe_hosts_set()
+ {
+ $this->runWithEnv("ALLOWED_IFRAME_HOSTS", "http://example.com", function() {
+ $resp = $this->get("/");
+ foreach ($resp->headers->getCookies() as $cookie) {
+ $this->assertEquals("none", $cookie->getSameSite());
+ }
+ });
+ }
+
+ public function test_secure_cookies_controlled_by_app_url()
+ {
+ $this->runWithEnv("APP_URL", "http://example.com", function() {
+ $resp = $this->get("/");
+ foreach ($resp->headers->getCookies() as $cookie) {
+ $this->assertFalse($cookie->isSecure());
+ }
+ });
+
+ $this->runWithEnv("APP_URL", "https://example.com", function() {
+ $resp = $this->get("/");
+ foreach ($resp->headers->getCookies() as $cookie) {
+ $this->assertTrue($cookie->isSecure());
+ }
+ });
+ }
+
+ public function test_iframe_csp_self_only_by_default()
+ {
+ $resp = $this->get("/");
+ $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
+ $frameHeaders = $cspHeaders->filter(function ($val) {
+ return Str::startsWith($val, 'frame-ancestors');
+ });
+
+ $this->assertTrue($frameHeaders->count() === 1);
+ $this->assertEquals('frame-ancestors \'self\'', $frameHeaders->first());
+ }
+
+ public function test_iframe_csp_includes_extra_hosts_if_configured()
+ {
+ $this->runWithEnv("ALLOWED_IFRAME_HOSTS", "https://a.example.com https://b.example.com", function() {
+ $resp = $this->get("/");
+ $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
+ $frameHeaders = $cspHeaders->filter(function($val) {
+ return Str::startsWith($val, 'frame-ancestors');
+ });
+
+ $this->assertTrue($frameHeaders->count() === 1);
+ $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeaders->first());
+ });
+
+ }
+
+}
\ No newline at end of file