RoundCube

Apache

  • Do not set X-Frame-Options as it’s already done by RoundCube itself.

  • .htaccess are ignored because AllowOverride All isn’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