From 7bcf2f802170dea5b63efd58a3ce83cff0c92bb2 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Fri, 11 Sep 2026 17:59:01 +0200 Subject: [PATCH] fix(config): mirror the nginx deny rules in the Apache template, Secure cookie by scheme ht.access refused nothing that ng.inx refuses: /core, /views, /vendor, /tmp, the writable assets areas, composer.json/.lock, dumps and backups, and PHP under assets/ were all left to per-directory .htaccess files. Those only apply with AllowOverride, and they were written in the Apache 2.2 dialect, which a 2.4 server without mod_access_compat answers with a 500. The rewrite rules now live in ht.access ahead of the assets/manager passthrough, and every shipped .htaccess carries both dialects behind mod_authz_core guards. manager/.htaccess switched mod_security off with a 1.x directive; dropped. install/.htaccess stripped the Content-Security-Policy header the installer itself sets with a nonce; dropped. The session cookie's Secure flag came from SESSION_SECURE_COOKIE alone, and the session proxy, which is the default, reads the config value rather than detecting HTTPS, so an HTTPS site never got the flag without setting the variable. Unset, the option now follows the request scheme; the variable still overrides it for a TLS-terminating proxy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0136CYHW4NXMDFA8gfuNCx7R --- assets/.thumbs/.htaccess | 9 ++- assets/backup/.htaccess | 9 ++- assets/cache/.htaccess | 9 ++- assets/cache/images/.htaccess | 9 ++- assets/docs/.htaccess | 18 ++++- assets/export/.htaccess | 9 ++- assets/files/.htaccess | 9 ++- assets/images/.htaccess | 9 ++- assets/import/.htaccess | 9 ++- assets/js/.htaccess | 9 ++- assets/js/fileapi/.htaccess | 9 ++- assets/js/jeditable/.htaccess | 9 ++- assets/site/.htaccess | 9 ++- core/.htaccess | 9 ++- core/config/session.php | 9 ++- .../Security/ApacheConfigHardeningTest.php | 80 +++++++++++++++++++ ht.access | 16 ++++ install/.htaccess | 3 - install/assets/.htaccess | 8 +- install/src/.htaccess | 8 +- manager/.htaccess | 5 -- manager/media/browser/mcpuk/core/.htaccess | 9 ++- manager/media/browser/mcpuk/doc/.htaccess | 9 ++- manager/media/browser/mcpuk/lang/.htaccess | 9 ++- manager/media/browser/mcpuk/lib/.htaccess | 9 ++- manager/media/browser/mcpuk/tpl/.htaccess | 9 ++- views/.htaccess | 9 ++- 27 files changed, 265 insertions(+), 53 deletions(-) create mode 100644 core/tests/Unit/Security/ApacheConfigHardeningTest.php delete mode 100644 install/.htaccess diff --git a/assets/.thumbs/.htaccess b/assets/.thumbs/.htaccess index 6c1cc6e2da..785fc3b368 100644 --- a/assets/.thumbs/.htaccess +++ b/assets/.thumbs/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/backup/.htaccess b/assets/backup/.htaccess index ff2beb844b..fb1de45bdb 100755 --- a/assets/backup/.htaccess +++ b/assets/backup/.htaccess @@ -1,2 +1,7 @@ -order deny,allow -deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/cache/.htaccess b/assets/cache/.htaccess index ff2beb844b..fb1de45bdb 100644 --- a/assets/cache/.htaccess +++ b/assets/cache/.htaccess @@ -1,2 +1,7 @@ -order deny,allow -deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/cache/images/.htaccess b/assets/cache/images/.htaccess index 83c6d382fb..1d92c1dc81 100755 --- a/assets/cache/images/.htaccess +++ b/assets/cache/images/.htaccess @@ -1,2 +1,7 @@ -order deny,allow -allow from all + + Require all granted + + + Order deny,allow + Allow from all + diff --git a/assets/docs/.htaccess b/assets/docs/.htaccess index 1fce156e5a..f097ca5458 100755 --- a/assets/docs/.htaccess +++ b/assets/docs/.htaccess @@ -1,9 +1,19 @@ IndexIgnore */* - Order Allow,Deny - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/export/.htaccess b/assets/export/.htaccess index 6c1cc6e2da..785fc3b368 100644 --- a/assets/export/.htaccess +++ b/assets/export/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/files/.htaccess b/assets/files/.htaccess index 6c1cc6e2da..785fc3b368 100644 --- a/assets/files/.htaccess +++ b/assets/files/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/images/.htaccess b/assets/images/.htaccess index 6c1cc6e2da..785fc3b368 100644 --- a/assets/images/.htaccess +++ b/assets/images/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/import/.htaccess b/assets/import/.htaccess index 6c1cc6e2da..785fc3b368 100644 --- a/assets/import/.htaccess +++ b/assets/import/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/js/.htaccess b/assets/js/.htaccess index 6c1cc6e2da..785fc3b368 100755 --- a/assets/js/.htaccess +++ b/assets/js/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/js/fileapi/.htaccess b/assets/js/fileapi/.htaccess index 6c1cc6e2da..785fc3b368 100755 --- a/assets/js/fileapi/.htaccess +++ b/assets/js/fileapi/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/js/jeditable/.htaccess b/assets/js/jeditable/.htaccess index 6c1cc6e2da..785fc3b368 100755 --- a/assets/js/jeditable/.htaccess +++ b/assets/js/jeditable/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/assets/site/.htaccess b/assets/site/.htaccess index 6c1cc6e2da..785fc3b368 100644 --- a/assets/site/.htaccess +++ b/assets/site/.htaccess @@ -1,5 +1,10 @@ IndexIgnore */* - Order Deny,Allow - Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/core/.htaccess b/core/.htaccess index ff2beb844b..fb1de45bdb 100644 --- a/core/.htaccess +++ b/core/.htaccess @@ -1,2 +1,7 @@ -order deny,allow -deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/core/config/session.php b/core/config/session.php index 6434fea78d..e59d1819b5 100644 --- a/core/config/session.php +++ b/core/config/session.php @@ -151,8 +151,15 @@ | to the server if the browser has a HTTPS connection. This will keep | the cookie from being sent to you if it can not be done securely. | + | Unset, it follows the current request: an HTTPS site gets the Secure + | flag without configuration. Set it explicitly behind a TLS-terminating + | proxy that talks plain HTTP to PHP. + | */ - 'secure' => env('SESSION_SECURE_COOKIE', false), + 'secure' => env('SESSION_SECURE_COOKIE') ?? ( + (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (int)($_SERVER['SERVER_PORT'] ?? 0) === 443 + ), /* |-------------------------------------------------------------------------- diff --git a/core/tests/Unit/Security/ApacheConfigHardeningTest.php b/core/tests/Unit/Security/ApacheConfigHardeningTest.php new file mode 100644 index 0000000000..030b098ef4 --- /dev/null +++ b/core/tests/Unit/Security/ApacheConfigHardeningTest.php @@ -0,0 +1,80 @@ +toContain('RewriteRule ^(core|views|vendor|tmp)(/|$) - [F,L]') + ->toContain('RewriteRule ^assets/(cache|backup|export|import)(/|$) - [F,L]') + ->toContain('RewriteRule ^assets/.*\.php$ - [F,L,NC]') + ->toContain('RewriteRule \.(sql|sqlite|db|log|bak|old|orig|save|swp|swo|tpl|inc|ini|env|dist|example|yml|yaml|lock|tar)(\.(gz|bz2|xz|zip|tgz))?$ - [F,L,NC]') + ->toContain('RewriteRule ^(composer\.(json|lock)|phpstan\.neon|publiccode\.yml|AGENTS\.md|README\.md|ht\.access|ng\.inx|config\.php(\.example)?)$ - [F,L]'); + + // the deny rules must run before the assets/manager passthrough that ends rewriting + expect(strpos($htaccess, 'RewriteRule ^(core|views|vendor|tmp)')) + ->toBeLessThan(strpos($htaccess, 'RewriteRule ^(manager|assets|js|css|images|img)/.*$ - [L]')); +}); + +it('writes every shipped per-directory .htaccess in both the 2.2 and the 2.4 dialect', function (string $file) { + $source = evoFile($file); + + if (preg_match('/^\s*(Order|Deny|Allow|Require)\b/mi', $source) !== 1) { + expect(true)->toBeTrue(); // rewrite-only file, nothing to check + return; + } + + expect($source) + ->toContain('') + ->toContain('') + ->and(preg_match_all('/Require all (denied|granted)/', $source)) + ->toBe(preg_match_all('/(Deny|Allow) from all/i', $source)); + + // a bare 2.2 directive outside its guard is a 500 on a 2.4 server without mod_access_compat + expect(preg_match('/^\s*(Order|Deny from|Allow from)\b/mi', preg_replace('/.*?<\/IfModule>/s', '', $source))) + ->toBe(0); +})->with(function () { + $root = dirname(__DIR__, 4); + $out = []; + foreach (explode("\n", trim((string)shell_exec('git -C ' . escapeshellarg($root) . ' ls-files'))) as $path) { + if (preg_match('~(^|/)\.htaccess$~', $path)) { + $out[] = $path; + } + } + + return $out; +}); + +it('does not ship an installer .htaccess that strips the CSP the installer sets', function () { + expect(file_exists(dirname(__DIR__, 4) . '/install/.htaccess'))->toBeFalse(); +}); + +it('no longer switches mod_security off for the manager', function () { + expect(evoFile('manager/.htaccess'))->not->toContain('SecFilterEngine'); +}); + +it('marks the session cookie Secure on https unless told otherwise', function () { + $source = evoFile('core/config/session.php'); + + expect($source) + ->toContain("'secure' => env('SESSION_SECURE_COOKIE') ?? (") + ->toContain("\$_SERVER['HTTPS'] !== 'off'") + ->and($source)->not->toContain("env('SESSION_SECURE_COOKIE', false)"); +}); diff --git a/ht.access b/ht.access index febb638ee9..555c6952d9 100644 --- a/ht.access +++ b/ht.access @@ -61,6 +61,22 @@ RewriteRule .* - [F,L] #RewriteCond %{THE_REQUEST} \s/+index\.php\?q=([^\s&]+) [NC] #RewriteRule ^ /%1? [R=301,L] +# Paths that must never be reachable over HTTP (mirrors ng.inx). +# core, views, vendor, tmp - CMS core (config, .env, storage), Blade layouts, deps, scratch +# assets/(cache|backup|export|import) - generated and writable areas; the per-directory +# .htaccess files there only work with AllowOverride +RewriteRule ^(core|views|vendor|tmp)(/|$) - [F,L] +RewriteRule ^assets/(cache|backup|export|import)(/|$) - [F,L] + +# Root-level files that fingerprint the install or describe its layout. +RewriteRule ^(composer\.(json|lock)|phpstan\.neon|publiccode\.yml|AGENTS\.md|README\.md|ht\.access|ng\.inx|config\.php(\.example)?)$ - [F,L] + +# Backups, dumps, editor leftovers and PHP include fragments anywhere, including compressed copies. +RewriteRule \.(sql|sqlite|db|log|bak|old|orig|save|swp|swo|tpl|inc|ini|env|dist|example|yml|yaml|lock|tar)(\.(gz|bz2|xz|zip|tgz))?$ - [F,L,NC] + +# No PHP execution under assets - uploads live here. +RewriteRule ^assets/.*\.php$ - [F,L,NC] + # Exclude /assets and /manager directories and images from rewrite rules RewriteRule ^(manager|assets|js|css|images|img)/.*$ - [L] RewriteRule \.(jpg|jpeg|png|gif|ico)$ - [L] diff --git a/install/.htaccess b/install/.htaccess deleted file mode 100644 index ee0d892062..0000000000 --- a/install/.htaccess +++ /dev/null @@ -1,3 +0,0 @@ - - Header always unset Content-Security-Policy - \ No newline at end of file diff --git a/install/assets/.htaccess b/install/assets/.htaccess index 3a42882788..fb1de45bdb 100644 --- a/install/assets/.htaccess +++ b/install/assets/.htaccess @@ -1 +1,7 @@ -Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/install/src/.htaccess b/install/src/.htaccess index 3a42882788..fb1de45bdb 100644 --- a/install/src/.htaccess +++ b/install/src/.htaccess @@ -1 +1,7 @@ -Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/manager/.htaccess b/manager/.htaccess index 79e352df19..a98fff4be3 100644 --- a/manager/.htaccess +++ b/manager/.htaccess @@ -8,11 +8,6 @@ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ index.php [L,QSA] - -# Turn the filtering engine Off -SecFilterEngine Off - - ExpiresActive off diff --git a/manager/media/browser/mcpuk/core/.htaccess b/manager/media/browser/mcpuk/core/.htaccess index d61b264c5e..5f01affee9 100755 --- a/manager/media/browser/mcpuk/core/.htaccess +++ b/manager/media/browser/mcpuk/core/.htaccess @@ -1,4 +1,9 @@ -Order allow,deny -Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/manager/media/browser/mcpuk/doc/.htaccess b/manager/media/browser/mcpuk/doc/.htaccess index d61b264c5e..5f01affee9 100755 --- a/manager/media/browser/mcpuk/doc/.htaccess +++ b/manager/media/browser/mcpuk/doc/.htaccess @@ -1,4 +1,9 @@ -Order allow,deny -Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/manager/media/browser/mcpuk/lang/.htaccess b/manager/media/browser/mcpuk/lang/.htaccess index d61b264c5e..5f01affee9 100755 --- a/manager/media/browser/mcpuk/lang/.htaccess +++ b/manager/media/browser/mcpuk/lang/.htaccess @@ -1,4 +1,9 @@ -Order allow,deny -Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/manager/media/browser/mcpuk/lib/.htaccess b/manager/media/browser/mcpuk/lib/.htaccess index d61b264c5e..5f01affee9 100755 --- a/manager/media/browser/mcpuk/lib/.htaccess +++ b/manager/media/browser/mcpuk/lib/.htaccess @@ -1,4 +1,9 @@ -Order allow,deny -Deny from all + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/manager/media/browser/mcpuk/tpl/.htaccess b/manager/media/browser/mcpuk/tpl/.htaccess index 7484f13a00..7b313dc35d 100755 --- a/manager/media/browser/mcpuk/tpl/.htaccess +++ b/manager/media/browser/mcpuk/tpl/.htaccess @@ -1,4 +1,9 @@ -Order allow,deny -Deny from all + + Require all denied + + + Order deny,allow + Deny from all + \ No newline at end of file diff --git a/views/.htaccess b/views/.htaccess index ff2beb844b..fb1de45bdb 100755 --- a/views/.htaccess +++ b/views/.htaccess @@ -1,2 +1,7 @@ -order deny,allow -deny from all + + Require all denied + + + Order deny,allow + Deny from all +