4. Web server hardening
Start from Joomla's htaccess.txt
Rename htaccess.txt to .htaccess. Without it, Apache serves Joomla with no extra protection at all.
Tested: with the shipped file active, index.php?x=base64_encode(abc) and index.php?q=%3Cscript%3E returned 403, and responses gained X-Content-Type-Options: nosniff. But it does not hide version or distribution files. These were all still readable (200):
/administrator/manifests/files/joomla.xml(contains<version>6.1.4</version>)/README.txt,/LICENSE.txt,/htaccess.txt,/web.config.txt/language/en-GB/langmetadata.xml/cli/joomla.php
Add these rules to the end of .htaccess
## ---- Additional hardening (tested on Joomla 6.1.4 / Apache 2.4.58) ----
# 1. Hide version-revealing and distribution files
<FilesMatch "(?i)^(README|LICENSE|htaccess|web\.config|configuration\.php-dist|robots\.txt\.dist)(\.txt)?$">
Require all denied
</FilesMatch>
<IfModule mod_rewrite.c>
RewriteRule ^administrator/manifests/ - [F,L]
RewriteRule ^(cli|installation)/ - [F,L]
RewriteRule ^(administrator/)?language/.+\.xml$ - [F,L]
</IfModule>
# 2. Never execute scripts from upload, cache, tmp and log folders
<IfModule mod_rewrite.c>
RewriteRule ^(images|media|files|tmp|cache|administrator/cache|administrator/logs)/.*\.(php\d?|phtml|phar|pht|phps|cgi|pl|py|sh|asp|aspx|jsp)$ - [NC,F,L]
</IfModule>
# 3. Only if you do NOT use the Joomla Web Services API
<IfModule mod_rewrite.c>
RewriteRule ^api(/|$) - [F,L]
</IfModule>
Tested: every file in the list above returned 403, including lowercase variants such as /readme.txt. Test PHP files placed in all seven folders returned 403, as did .PhAr, shell.jpg.php and files several subfolders deep. The home page, /administrator/, CSS/JS under /media, images and robots.txt all still returned 200. Joomla's own cache files under administrator/cache are included by PHP, never requested over HTTP, so the admin kept working.
The .phar rule matters on Ubuntu: its default PHP handler executes .phar and .phtml as well as .php.
The gap: a planted .htaccess turns rule 2 off
Rewrite rules are not inherited by a folder whose own .htaccess turns the rewrite engine on. In testing, an images/.htaccess containing only RewriteEngine On made images/zz_test.php execute again. An attacker who can write one file can usually write two, so rule 2 is defence in depth, not a fix.
If you control the server config, close the gap there. AllowOverride is ignored inside <DirectoryMatch> (tested: the planted .htaccess still worked), so each folder needs a plain <Directory> block:
# /etc/apache2/conf-available/joomla-noexec.conf (adjust /var/www/html)
<Directory "/var/www/html/images">
AllowOverride None
</Directory>
# ...repeat for files, media, tmp, cache, administrator/cache, administrator/logs
<DirectoryMatch "^/var/www/html/(images|files|media|tmp|cache|administrator/cache|administrator/logs)(/|$)">
<FilesMatch "(?i)\.(php\d?|phtml|phar|pht|phps|cgi|pl|py|sh)$">
Require all denied
</FilesMatch>
RemoveHandler .php .phtml .phar
# mod_php only; remove the next line for PHP-FPM
php_admin_flag engine off
</DirectoryMatch>
Enable it with a2enconf joomla-noexec && apachectl configtest && systemctl reload apache2.
Tested: with this in place, a planted images/.htaccess that set RewriteEngine On and mapped .jpg to PHP was ignored: .php files returned 403 and evil.jpg was served as plain text instead of running. The same held for a planted .htaccess several folders deep.
PHP settings
These go in the PHP configuration for the web server (for example /etc/php/8.3/apache2/conf.d/99-joomla.ini or the PHP-FPM pool):
expose_php = Off
display_errors = Off
log_errors = On
allow_url_include = Off
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
open_basedir = /var/www/html:/var/joomla-private:/tmp
session.use_strict_mode = 1
Tested: with all of these active, the front end, admin login, Control Panel, System Information, Global Configuration, Media, Extensions → Install and Joomla Update screens all loaded with no errors. A test script confirmed system() was disabled and reading /etc/passwd was blocked by open_basedir. The CLI uses a separate php.ini, so cli/joomla.php is unaffected.
Check your own extensions before using disable_functions. Some backup and image tools call exec(); test on staging first.
Nginx
Joomla does not ship an Nginx file. The same intent translates to location blocks that deny all the paths in rule 1 and return 403 for script extensions under the writable folders. These Nginx rules were not part of this test run.
