This post will outline recommended steps to harden phpList after install to make it reasonably secure.

phpList is the most popular open-source software for managing mailing lists. Like wordpress, they have a phplist.com for paid hosting services and phplist.org for free self-hosting.

Earlier this week, it was announced that phpList had a critical security vulnerability permitting an attacker to bypass authentication and login as an administrator using an incorrect & carefully-crafted password in some cases. This bug is a result of the fact that [a] PHP is a loosely typed language and [b] the phpList team was using the ' == ' operator to test for equality of the user's hashed password against the DB. This security pitfall has been known in PHP since at least 2010 (a decade ago!), but I'm sure the same mistake will be made again..

Indeed, security is porous. There's no such thing as 100% vulnerability-free code, and phpList is no exception. But if we're careful in adding layers of security to our infrastructure, then we might be able to protect ourselves from certain 0-days.

That said, here's my recommended steps to making your phpList install reasonably secure.

Software and Version Notes

Note that this guide was written against the following software and versions:

CentOS 7.7.1908 nginx 1.16.1 php 7.3.14 phpList v3.5.1

If you're using a different OS, web server, or php version, then adaptations to the below commands may be necessary.

Terms Defined

Throughout this guide we'll utilize the following terms:

vhost dir: This is the directory where we store files relevant for the phpList virtual host. It is not served publicly by the webserver. In this guide, our phpList vhost dir is ' /var/www/html/phplist/ '

docroot dir: This is the document root directory for phpList. It is served publicly by the web server. In this guide, our phpList docroot dir is ' /var/www/html/phplist/public_html/ '

Prerequisite Hardening

Before proceeding with hardening phpList, it is recommended that you first harden the stack ontop of which phpList sits. Hardening these components is outside the scope of this guide, but tips are given to guide the user.

OS hardening

Your OS should be setup to automatically download and install critical security updates through its package manager. In CentOS/RHEL, that's done with the yum-cron package. On Debian, use the unattended-upgrades package.

You should also have installed & configured a network firewall such as iptables to be as restrictive as possible. For example, even if you've configured your DB to bind only to the local interface (and you should!), it's still good practice to setup a firewall such that it blocks traffic to your DB process just in-case it ever accidentally gets bound to your Internet-facing IP address in the future. This is a good example of Layered Security.

And you should look into a HIDS for endpoint security. Personally, I recommend OSSEC (or Wazuh ) with Active Response enabled. Or at least fail2ban

It's also recommended that you spend some time hardening your kernel.

Web Server (Apache, Nginx, etc)

You should spend some time hardening your phpList site's web server configuration. Specifically, look into:

PHP

You should also spend some time hardening your php configuration. Specifically, look into:

Strict whitelist of open_basedir including only your vhost dirs, php sessions, temp, cache, and libraries directories (ie: pear) Strict use of disable_functions for dangerous functions, such as ini_set , exec , shell_exec , system , etc Turn off expose_php and phpinfo (information leakage) Limit max_execution_time, max_input_time, memory_limit, post_max_size, upload_max_filesize, max_file_uploads , etc Turn off display_errors and display_startup_errors Turn on log_errors Change upload_tmp_dir, session.save_path, soap.wsdl_cache_dir , etc to a directory that's only owned by the user running the php process (ie: not 0777 /tmp) Harden session.hash_function, session.use_strict_mode, session.referer_check, session.cookie_httponly, session.cookie_secure , etc Et cetera

Mysql/Maria DB

And you should spend some time hardening your mysql/maria DB configuration. Specifically, look into:

Disabling networking (use unix sockets) if possible Otherwise, bind only to localhost Use skip-show-database (Information leakage) Use symbolic-links, local-infile, etc On a new install, drop the test DB Remove default anonymous user accounts Take actions to protect where your mysql passwords are written to disk, including your .mysql_history file Reset the root password! Et cetera

Hardening phpList

This section is specific to hardening phpList.

Web Server

This section will suggest changes that should be made to phpList's site-specific configuration file for nginx.

General Prereqs

First, you'll want to force all traffic on port 80 to 443, require https, harden your https tls versions (hint: disable ssl), harden your cipher list, enable hsts, enable hpkp, add a waf, configure ModSecurity, etc. All of these tasks are outside the scope of this article, but you can validate your config with Qualys' SSL Labs Test.

phpList site-specific nginx config

Add the following blocks to your nginx config for phpList inside of your server{} block:

# make sure Indexing is off autoindex off; # deny access to any files with a .php extension in the uploads directory location ~* /uploadimages/.*\.php$ { deny all; } # prevent access to our passwords! location ~* config.php { deny all; } # block access to hidden "dot" files, such as # .htaccess, .svn, .git, .github, etc location ~* /\. { deny all; }

In my config I also have this location block to redirect all requests with the ' .php ' file extension to the fastcgi proxy running on port 9000. But this varies a lot, and it may not match what you need.

location ~ \.php$ { try_files $uri $uri/ /index.php?q=$args =404; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 600s; }

Note that nginx will only apply the config options for a given URI matching a single location block. For example, a request for ' /uploadimages/malicious.php ' would match both the first location block shown in the first snippet above, and it would also match the only location block shown in the second snippet above (for fastcgi). In the event of this conflict, nginx will simply apply the options for the first location block that matches. Therefore, it's critical that these ' deny all; ' location blocks appear in your nginx config before other location blocks.

Nginx configs can be complex with includes across many config files. As such, after adding the above blocks to your phpList nginx config, you should test to make sure that the following requests result in a 403 Forbidden from your server

Any request ending in 'config.php', such as example.com/config.php Any php file in the uploadimages directory, such as example.com/uploadimages/malicious.php Any file that starts with a dot, such as example.com/.github

Optional: you can choose to require auth_basic for the admin section of your phpList site. If used, your admins will have to pass through two sets of authentication barriers to login. Do this if you can get away with it (ie: your marketing director doesn't throw a fuss) as it would make your site invulnerable if there's an Authentication Bypass vulnerability discovered in phpList--such as the one that was just fixed earlier this week (CVE-2020-8547).

To require auth_basic for the admin section of your phpList site, add this block. Note that the fastcgi bits are redundant. If you don't specify them again, then nginx will serve the php code right back to the client (after successful basic http auth) due to the location conflict explained above.

# require basic http auth for admin area location ~* /lists/admin/(index.php)?$ { auth_basic "auth required"; auth_basic_user_file /var/www/html/phplist/.htpasswd; try_files $uri $uri/ /index.php?q=$args =404; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 600s; }

You'll need to create a file holding the username & hashed passphrase in the vhost dir. This can be done with the following command:

htpasswd -cB /var/www/html/phplist/.htpasswd admin

Cross-Origin Resource Sharing

If you plan on submitting data to your phpList site from a 3rd party domain (ie: AJAX newsletter registration or API queries), then you'll need to define CORS via the phpList site defining the ' Access-Control-Allow-Origin ' header.

Of course, if you don't need to accept data from any 3rd party domains, then don't set this option. That's ideal.

If you need to accept 3rd party requests from exactly one 3rd party domain, then you can just define that domain to the phpList built-in ' ACCESS_CONTROL_ALLOW_ORIGIN ' constant. For example

// allow AJAX queries to add subscribers to our db from other domains // Note: The ACCESS_CONTROL_ALLOW_ORIGIN header does not support multiple // domains, so we instead have to maintain a whitelist logically and // dynamically return the relevant domain iff it's in the whitelist. // Therefore, we actually override this phplist ACCESS_CONTROL_ALLOW_ORIGIN // header in our nginx config. See the relevant nginx config file. define('ACCESS_CONTROL_ALLOW_ORIGIN', "https://one.example.com" );

But, as the comment above suggests, the ' Access-Control-Allow-Origin ' header actually lacks the ability to specify a set of domains. So, rather than going the less-secure route of setting ' ACCESS_CONTROL_ALLOW_ORIGIN ' to ' * ', the w3c recommends that we "generate the Access-Control-Allow-Origin header dynamically". We do that with nginx:

location ~ \.php$ { try_files $uri $uri/ /index.php?q=$args =404; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 600s; # handle cors whitelist for ajax subscription to phplist proxy_hide_header Access-Control-Allow-Origin; if ( $http_origin ~ "^https://(one.example.com|two.example.com)$" ) { add_header Access-Control-Allow-Origin $http_origin; } }

.htaccess

Finally, if you're using Apache, then you may want to look into setting 'AllowOverride None' , but Nginx ignores .htaccess files.

iptables

This is surprisingly controversial, but I believe that a web server's purpose is to serve content. I do not condone a web application initiating web requests; it should only respond to requests across an already-established connection that was initiated by a client.

The moment a web server starts initiating web requests, my eyebrows raise and a red flag is flown. That's the behaviour of something malicious phoning home or downloading a payload.

Indeed, you can cut the legs off of an exploit chain by denying the web server from being able to initiate web requests. I do that with these iptables rules:

/sbin/iptables -A OUTPUT -d 127.0.0.1/32 -j ACCEPT /sbin/iptables -A OUTPUT -m state --state RELATED,ESTABLISHED -j ACCEPT ... /sbin/iptables -A OUTPUT -m owner --uid-owner nginx -p tcp -j DROP ...

Bascially, this first matches and ACCEPT s any packets on the OUTPUT chain that are already RELATED or ESTABLISHED (existing tcp connections).

If that doesn't match, then the next rule will DROP any tcp packet that was initated by the user ' nginx '.

Mysql/Maria DB

The phpList install guide doesn't provide much specifics on the creation of the db user, but I'll add a couple things you should do here to make it more secure

First, use a 32-character randomly generated passphrase Limit the user's Host component to be as restrictive as possible (ie: localhost ). Only GRANT the SELECT, INSERT, UPDATE, DELETE, and CREATE permissions for the phpList db to the phpList user.

GRANT SELECT, INSERT, UPDATE, DELETE, CREATE ON phplist_db.* TO 'phplist_user'@'localhost' IDENTIFIED BY 'obfuscated1234567890123456789012'; FLUSH PRIVILEGES;

Here's what the user's permissions should look like in the ' mysql ' database's ' db ' table.

MariaDB [mysql]> select * from db where User = 'phplist_user'; +-----------+------------+--------------+-------------+-------------+-------------+-------------+-------------+-----------+------------+-----------------+------------+------------+-----------------------+------------------+------------------+----------------+---------------------+--------------------+--------------+------------+--------------+ | Host | Db | User | Select_priv | Insert_priv | Update_priv | Delete_priv | Create_priv | Drop_priv | Grant_priv | References_priv | Index_priv | Alter_priv | Create_tmp_table_priv | Lock_tables_priv | Create_view_priv | Show_view_priv | Create_routine_priv | Alter_routine_priv | Execute_priv | Event_priv | Trigger_priv | +-----------+------------+--------------+-------------+-------------+-------------+-------------+-------------+-----------+------------+-----------------+------------+------------+-----------------------+------------------+------------------+----------------+---------------------+--------------------+--------------+------------+--------------+ | localhost | phplist_db | phplist_user | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | +-----------+------------+--------------+-------------+-------------+-------------+-------------+-------------+-----------+------------+-----------------+------------+------------+-----------------------+------------------+------------------+----------------+---------------------+--------------------+--------------+------------+--------------+ 1 row in set (0.00 sec) MariaDB [mysql]>

Move config.php outside docroot

Probably the most important file in our phpList install is the one that stores the password of our user with read/write access to our DB containing all of our subscribers' personal information. And that password is necessarily stored in cleartext on our filesystem.

By default, phpList puts this file not in the vhost dir, but in the docroot dir! In most cases this won't be an issue, but it could result in leaking our DB password over the public Internet if either:

our web server server serves the config.php file to a client in plaintext without first processing it as a php file, or a copy of config.php gets accidentally stored to the docroot without the .php file extension

The first case could (and does) happen. If, for example, an admin restarts a php fastcgi backend without first stopping the nginx web server, then your server may happily spit your DB password back at clients who request it. And what if there's a mistake in the php config they're trying to change (perhaps after upgrading the php package?)--meanwhile nginx may be serving unprocessed php code directly to users for minutes, hours, or days? Sure, this shouldn't happen. But the fact is that it does happen. Maybe not to you. Maybe it'll be the less cautious sysadmin after you..

The second case is even more common. How many times have you stumbled on files like '.config.php.swp' or 'config.php~' or '#config.php' lying around? Whether it's vi or nano or emacs , editors use these swap files for locks and backups. In the best case, they're only present for a few seconds during a change. In the worst case, a session gets killed and they stick around for years. Or what if a well-intentioned (but overly tired) sysadmin does a ` cp config.php config.php.bak ` before making a change? Again, it happens. A lot. And the bad guys know this. Here's some requests I pulled off one of my servers running wordpress:

"GET /wp-config.php~ HTTP/1.1" 404 25575 "https://opensourceecology.org/wp-config.php~" "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.85 Safari/537.36" "GET /wp-config.phpbak HTTP/1.1" 404 30862 "-" "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36" "GET /wp-config.php_old HTTP/1.1" 404 30862 "-" "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36" "GET /wp-config.txt HTTP/1.1" 404 30906 "-" "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36" "-" "GET /wp-config.bak HTTP/1.1" 403 215 "-" "Mozilla/5.0 (Windows NT 6.3; Win64; x6 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36" "GET /wp-config.php.save HTTP/1.1" 404 91583 "-" "Mozilla/5.0 (Windows NT 6.3; Wi n64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36" "GET /wp-config.php.bak HTTP/1.1" 404 91581 "-" "Mozilla/5.0 (Windows NT 6.3; Win 64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36" "GET /wp-config.php.swp HTTP/1.1" 404 91581 "-" "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36"

Yeah, we get spammed like that with automated scrapers trying to steal our wordpress config file's contents every month.

For all these reasons and more, it's wise to just keep your damn config.php file outside of the docroot!

First, let's move config.php from the docroot dir at ' /var/www/html/phplist/public_html/lists/config/config.php ' to the vhost dir at ' /var/www/html/phplist/config.php '.

mv /var/www/html/phplist/public_html/lists/config/config.php /var/www/html/phplist/config.php

Now, unlike wordpress (but like MediaWiki), phpList won't just search for (and automagically include) your config file in the vhost dir located one directory above from the docroot. So, for phpList, we can add the following in-place for where phpList expects the config.php file to live at ' public_html/lists/config/config.php '

<?php # including separate file that contains the database password so that it is not stored within the document root. # For more info see: # * https://tech.michaelaltfield.net/2020/02/14/phplist-hardening-security/ # * https://www.mediawiki.org/wiki/Manual:Security # * https://wiki.r00tedvw.com/index.php/Mediawiki/Hardening $docRoot = dirname( __FILE__ ); require_once "$docRoot/../../../config.php"; ?>

And now we can have peace-of-mind when restarting the php service or editing our phpList config file in the vhost dir--not the docroot dir.

File Permissions

A hardened phpList's file permissions should be set such that:

Files in the ' public_html/uploadimages/ ' dir should be nginx:nginx 0600 All other files in the vhost dir should be root:nginx 0040 All other directories in the vhost dir should be nginx:nginx 0050

This is achievable with the following idempotent commands:

vhostDir="/var/www/html/phplist" chown -R root:nginx "${vhostDir}" find "${vhostDir}" -type d -exec chmod 0050 {} \; find "${vhostDir}" -type f -exec chmod 0040 {} \; [ -d "${vhostDir}/public_html/uploadimages" ] || mkdir "${vhostDir}/public_html/uploadimages" chown -R nginx:nginx "${vhostDir}/public_html/uploadimages" find "${vhostDir}/public_html/uploadimages" -type d -exec chmod 0700 {} \; find "${vhostDir}/public_html/uploadimages" -type f -exec chmod 0600 {} \;

The above permissions are ideal because:

All of the files & directories that don't need write permissions should not have write permissions. That's every file in a phplist docroot except the folder ' public_html/uploadimages/ ' and its subfiles/dirs. World permissions (not-user && not-group) for all files & directories inside the docroot (and including the docroot dir itself!) should be set to 0 for all files & all directories. Excluding ' public_html/uploadimages/ ', these files should also not be owned by the user that runs a webserver (in cent, that's the ' nginx ' user). For even if the file is set to ' 0400 ', but it's owned by the ' nginx ' user, the 'nginx' user can ignore the permissions & write to it anyway. We don't want the nginx user (which runs the nginx process) to be able to modify files. If it could, then a compromised webserver could modify a php file and effectively do an arbitrary remote code execution. Excluding ' public_html/uploadimages/ ', all directories in the docroot (including the docroot dir itself!) should be owned by a group that contains the user that runs our webserver (in cent, that's the ' nginx ' user). The permissions for this group must include read access and must not include write access for files or directories. For even if a file is set to ' 0040 ', but the containing directory is ' 0060 ', any user in the group that owns the directory can delete the existing file and replace it with a new file, effectively ignoring the read-only permission set for the file.

Here's what the permissions should look like after running the above commands on a fresh install:

[root@mail phplist]# ls -lah total 136K d---r-x---. 6 root nginx 4.0K Feb 12 19:04 . drwxr-xr-x. 12 root root 4.0K Feb 12 19:02 .. d---r-x---. 2 root nginx 4.0K Feb 3 12:17 bin ----r-----. 1 root nginx 3.2K Feb 3 12:17 CODE_OF_CONDUCT.md ----r-----. 1 root nginx 2.5K Feb 3 12:17 CONTRIBUTING.md ----r-----. 1 root nginx 34K Feb 3 12:17 COPYING d---r-x---. 2 root nginx 4.0K Feb 3 12:17 doc d---r-x---. 2 root nginx 4.0K Feb 3 12:17 .github ----r-----. 1 root nginx 116 Feb 3 12:17 INSTALL ----r-----. 1 root nginx 34K Feb 3 12:17 LICENSE ----r-----. 1 root nginx 990 Feb 3 12:17 PEOPLE d---r-x---. 4 root nginx 4.0K Feb 12 19:20 public_html ----r-----. 1 root nginx 9.2K Feb 3 12:17 README.md ----r-----. 1 root nginx 2.5K Feb 3 12:17 TODO ----r-----. 1 root nginx 123 Feb 3 12:17 UPGRADE ----r-----. 1 root nginx 41 Feb 3 12:19 VERSION [root@mail phplist]# ls -lah public_html/ total 20K d---r-x---. 4 root nginx 4.0K Feb 12 19:20 . d---r-x---. 6 root nginx 4.0K Feb 12 19:04 .. ----r-----. 1 root nginx 566 Feb 3 12:17 index.html d---r-x---. 10 root nginx 4.0K Feb 3 12:18 lists drwx------. 2 nginx nginx 4.0K Feb 12 19:20 uploadimages [root@mail phplist]# ls -lah public_html/uploadimages/ total 8.0K drwx------. 2 nginx nginx 4.0K Feb 12 19:20 . d---r-x---. 4 root nginx 4.0K Feb 12 19:20 .. [root@mail phplist]# [root@mail phplist]# ls -lah public_html/lists/ total 120K d---r-x---. 10 root nginx 4.0K Feb 3 12:18 . d---r-x---. 4 root nginx 4.0K Feb 12 19:20 .. d---r-x---. 16 root nginx 4.0K Feb 3 12:19 admin ----r-----. 1 root nginx 260 Feb 3 12:17 api.php d---r-x---. 10 root nginx 4.0K Feb 3 12:19 base d---r-x---. 2 root nginx 4.0K Feb 3 12:17 config ----r-----. 1 root nginx 3.6K Feb 3 12:17 dl.php ----r-----. 1 root nginx 1.2K Feb 3 12:17 .htaccess d---r-x---. 3 root nginx 4.0K Feb 3 12:17 images ----r-----. 1 root nginx 708 Feb 3 12:17 index.html ----r-----. 1 root nginx 48K Feb 3 12:17 index.php d---r-x---. 2 root nginx 4.0K Feb 3 12:17 js ----r-----. 1 root nginx 11K Feb 3 12:17 lt.php d---r-x---. 2 root nginx 4.0K Feb 3 12:17 styles d---r-x---. 2 root nginx 4.0K Feb 3 12:19 texts d---r-x---. 3 root nginx 4.0K Feb 3 12:19 updater ----r-----. 1 root nginx 2.8K Feb 3 12:17 ut.php [root@mail phplist]#

phpList Settings

Admin Users

First of all, it should go without saying that your admin users should have good, long, unique, randomly generated passphrases.

Unfortunately, I don't know of any phpList plugins that present a password complexity strength meter and require a minimum password lenghth.

2FA

Unfortunately, I don't know of any phpList plugins to enable 2FA (ie: Google's TOTP defined in RFC 6238), but there is a very old, dead-end discussion about it on their forums.

Password Hasing

The default config.php file that shipped with phpList v3.5.1 included this line, defining the use of the SHA2 family's sha256() hash function for storing passwords in the database.

// check the extended config for more info // in most cases, it is fine to leave this as it is define('HASH_ALGO', 'sha256');

At the time of writing, sha256() is considered secure. If you're super-paranoid, you could consider changing it to sha3-512 or scrypt if your system supports it.

Worth noting: unfortunately, phpList does fall-back on md5() , which is absolutely insecure. Whatever you do, make sure that your system supports the hash function that you define so that you don't fall-back on md5() .

Other config.php

Make sure the following is set in your phpList config.php file

// define the images directory where users can upload images define('UPLOADIMAGES_DIR', 'uploadimages');

And this is not security-related, but I personally recommend these as well.

// send base64 to prevent the contents from being mangled define("HTMLEMAIL_ENCODING", "base64"); // attach images because otherwise gmail MITMs our links and causes 404s define('EMBEDUPLOADIMAGES',1);

Conclusion

I don't fault the phpList dev team for having security vulnerabilites. All software has bugs. The test of a project's security creditability is how they respond when a security flaw is unearthed: how long do they take to respond and push a release? How transparent are they with the community in pointing out the flaw, admitting fault, and taking steps to prevent issues in the future? Have they paid a third party to do a security audit of their code? Is the result of that security audit made public after the issues were addressed? Do they have a security bug bounty program?

Security is porous. It's not a matter of if; it's a matter of when. And it certainly pays for sysadmins to take steps to harden their services--which may protect you from the 0th day when code on your server is discovered to have a critical security flaw.

Related Resources

For more information, please reference the following links