RoundCube
Apache
Do not set
X-Frame-Optionsas it’s already done by RoundCube itself..htaccessare ignored becauseAllowOverride Allisn’t used, so rules must be defined in the Apache virtualhost configuration.
roundcube_headers
# Disable page indexing
Header set X-Robots-Tag "noindex, nofollow"
# replace 'merge' with 'append' for Apache < 2.2.9
# Header merge Cache-Control public env=!NO_CACHE
# HPKP - HTTP Public Key Pinning
# Only template - fill with your values
# Header always set Public-Key-Pins "max-age=3600; report-uri=\"\"; pin-sha256=\"\"; pin-sha256=\"\"" env=HTTPS
# X-Xss-Protection
# This header is used to configure the built in reflective XSS protection found in Internet Explorer, Chrome and Safari (Webkit).
Header always set X-XSS-Protection "1; mode=block"
# X-Frame-Options
# The X-Frame-Options header (RFC), or XFO header, protects your visitors against clickjacking attacks
# Already set by php code! Do not activate both options
# Header set X-Frame-Options SAMEORIGIN
# X-Content-Type-Options
# It prevents Google Chrome and Internet Explorer from trying to mime-sniff the content-type of a response away from the one being declared by the server.
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "same-origin"
# 23/8/2025
Header always set Permissions-Policy "accelerometer=(), camera=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), usb=(), gamepad=(), serial=()"
roundcube
<Directory {{ webmut_basedir }}/{{ item.name }}/public_html/config/>
Require all denied
</Directory>
<Directory {{ webmut_basedir }}/{{ item.name }}/public_html/temp/>
Require all denied
</Directory>
<Directory {{ webmut_basedir }}/{{ item.name }}/public_html/logs/>
Require all denied
</Directory>
RewriteEngine on
RewriteCond %{HTTP_USER_AGENT} ^(.*)Nmap(.*)$
RewriteRule .* - [F]
RewriteCond %{HTTP_USER_AGENT} ^(.*)Chrome/..\.(.*)$
RewriteRule .* - [F]
RewriteCond %{HTTP_USER_AGENT} ^(.*)Firefox/..\.(.*)$
RewriteRule .* - [F]
RewriteRule "(\/INSTALL)" - [L,R=404]
RewriteRule ^/?favicon\.ico$ skins/elastic/images/favicon.ico
# security rules:
# - deny access to files not containing a dot or starting with a dot
# in all locations except installer directory
RewriteRule ^/(?!installer|\.well-known\/|[a-zA-Z0-9]{16})(\.?[^\.]+)$ - [L,R=404]
# - deny access to some locations
RewriteRule ^/?(\.git|\.tx|SQL|bin|config|logs|temp|tests|vendor|program\/(include|lib|localization|steps)) - [L,R=404]
# - deny access to some documentation files
RewriteRule /?(README.*|CHANGELOG.*|SECURITY.*|meta\.json|composer\..*|jsdeps.json)$ - [L,R=404]
<IfModule mod_deflate.c>
SetOutputFilter DEFLATE
</IfModule>
# prefer to brotli over gzip if brotli is available
<IfModule mod_brotli.c>
SetOutputFilter BROTLI_COMPRESS
# some assets have been compressed, so no need to do it again
SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|web[pm]|woff2?)$ no-brotli
</IfModule>
<IfModule mod_expires.c>
ExpiresActive On
ExpiresDefault "access plus 1 month"
</IfModule>
FileETag MTime Size
<IfModule mod_autoindex.c>
Options -Indexes
</IfModule>
config.inc.php
https://github.com/roundcube/roundcubemail/issues/9789
config.inc.php
$config['session_samesite'] = 'Strict';
robots.txt
# su - rcube -s /bin/bash
$ cd ~/public_html
$ cat << EOF > robots.txt
User-agent: *
Disallow: /
EOF
$ chmod 644 robots.txt
security.txt
# su - rcube -s /bin/bash
$ install -d -m 0755 ~/public_html/.well-known
$ cd ~/public_html/.well-known
$ wget -N https://www.cgsecurity.org/.well-known/security.txt
$ chmod 644 security.txt
$ cd ~/public_html/public_html/
$ ln -s ../robots.txt .
$ ln -s ../.well-known/ .
Audit
roundcube_check.py
#!/usr/bin/python3
import requests
site = 'https://mail.cgsecurity.org/'
s = requests.session()
r = s.get(site)
assert 'Roundcube Webmail' in r.text
assert r.status_code == 200
for useragent in [
'Nmap',
'Chrome/12.3',
'Firefox/12.3',
]:
headers = {
'User-Agent': useragent,
}
status_code = s.get(site, headers=headers).status_code
print(str(headers).ljust(32), status_code, '✅' if status_code == 403 else '❌')
for filename in [
'robots.txt',
'.well-known/security.txt',
]:
url = site + filename
status_code = s.get(url).status_code
print(filename.ljust(32), status_code, '✅' if status_code == 200 else '❌')
for filename in [
'CHANGELOG.md',
'composer.json',
'composer.json-dist',
'composer.lock',
'INSTALL',
'LICENSE',
'README.md',
'SECURITY.md',
'UPGRADING',
'logs/errors.log',
'logs/sendmail.log',
'config/config.inc.php',
'config/config.inc.php.sample',
]:
url = site + filename
status_code = s.get(url).status_code
print(filename.ljust(32), status_code, '✅' if status_code in (403, 404) else '❌')
assert s.get(site + 'favicon.ico').status_code == 200
file = 'public_html/index.php'
url = site + file
status_code = s.get(url).status_code
print(file.ljust(32), status_code, '✅' if status_code == 404 else "⚠️" if status_code == 200 else '❌')
roundcube_check.py output
{'User-Agent': 'Nmap'} 403 ✅
{'User-Agent': 'Chrome/12.3'} 403 ✅
{'User-Agent': 'Firefox/12.3'} 403 ✅
robots.txt 200 ✅
.well-known/security.txt 200 ✅
CHANGELOG.md 404 ✅
composer.json 404 ✅
composer.json-dist 404 ✅
composer.lock 404 ✅
INSTALL 404 ✅
LICENSE 404 ✅
README.md 404 ✅
SECURITY.md 404 ✅
UPGRADING 404 ✅
logs/errors.log 404 ✅
logs/sendmail.log 404 ✅
config/config.inc.php 404 ✅
config/config.inc.php.sample 404 ✅
public_html/index.php 404 ✅
DocumentRoot points to PATH/public_html/public_html instead of PATH/public_html/
$ twa -d mail.cgsecurity.org|grep -v PASS
MEH(mail.cgsecurity.org): TWA-0206: X-Frame-Options is 'sameorigin', consider 'deny'
MEH(mail.cgsecurity.org): TWA-0213: Referrer-Policy specifies 'same-origin', consider 'no-referrer'?
FAIL(mail.cgsecurity.org): TWA-0219: Content-Security-Policy missing
FAIL(mail.cgsecurity.org): TWA-0220: Feature-Policy missing