Building a Secure WordPress server with LAMP on CentOS 7 and SELinux

Posted on 23 Jan 2017 by Ray Heffer

I’ve been maintaining my own web server for this WordPress blog for several years now, dating back to 2005 when I first starting using CentOS 4 to run my website. Those were the days I switched from authoring websites with Dreamweaver and FTP, to using WordPress and ditching those antiquated tools alltogether. Talking of antiquated, I’ve been working with Unix since 1992 and was a Linux sysadmin for an ISP for several years after that. I’ve also been learning along the way with each release of CentOS/RHEL, and I have taken much more notice of security hardening including the use of SELinux.

As an experiment, I posted a Tweet last night merely mentioning SELinux which resulted in some predictable responses including:

hate selinux!

…doesn’t everyone disable selinux at install? Usability vs security wins every time

I really don’t blame them for disliking SELinux, it seems that is a majority opinion. But I hope to change that! If I can get it working and playing nicely with my WordPress site then so can you. The reason I use SELinux isn’t to make my life any more difficult (though that could be true at times!), but it helps me better understand the inner-workings of CentOS 7 better, while providing significant levels of security.

Assumptions

For the remainder of this guide, I will assume that you know how to use Vi or another text editor, and you have a basic understanding of the Linux operating system.

Contents

  • Security Considerations
  • Do I really have to use SELinux?
  • Part 1: Deploying a new virtual private server (VPS)
    • Securing Access
    • Installing Core Packages
  • Basic Server Configuration
  • Part 2: Configuring IPtables
  • Part 3: MariaDB (MySQL)
  • Part 4: Migrating from another VPS Host (optional)
  • Part 5: Configuring LAMP (Linux, Apache, MariaDB/MySQL and PHP)
    • Directory Structures and Permissions
    • SFTP (SSH File Transfer)
    • Apache Configuration
    • Adding Our First Site (VirtualHost) for CloudWire.info
    • Configuring SSL
  • Part 6: Installing WordPress
    • Securing WordPress
    • Part 7: SELinux
    • How SELinux Works
    • Configuring SELinux to Play Nice with Apache
    • Troubleshooting SELinux
  • Conclusion

Security Considerations

Here are the primary security requirements that I will address with this guide:

  • SELinux will be enforcing security policies
  • IPtables will provide firewall functionality
  • TCP Wrapper will be configured for added security
  • 2-Factor authentication for WordPress
  • SSL certificates must be used and HTTP will redirect to HTTPS
  • FTP is not allowed, and all file transfers must be encrypted during transport (SSL / SSH file transfer)
  • Multiple websites (virtual hosts) with SFTP users chrooted (jailed) to their own directory.
  • SSH key based authentication (disable root access via SSH)
  • MySQL will be managed from SSH (no phpMyAdmin!)

Now just a few words on TCP Wrapper before someone freaks out. It’s old. Really old. It doesn’t protect all services running on the server and it uses the libwrap library which was created in 1990. However, it takes the blink of an eye to configure so think of it like one of these security latches your granny uses on her front door. There is no harm in using it.

Do I really have to use SELinux?

I get it. SELinux is a pain, especially if you tried it back in the days of CentOS/RHEL 4. Oh my, I thought about throwing it all out of the window back then. But it’s not really that bad, so ignore the negative comments and learn how to implement it properly.

The NSA (National Security Agency) created SELinux along with Red Hat who continue to be a major contributor. It’s a Mandatory Access Control (MAC) system that sets a fixed (Targeted) policy for access. For example, if a policy prevents a user or process from accessing a directory then it will be prevented by SELinux. Period.

For the purposes of this guide I will focus on the targeted policy, since the other option to use MLS (Multi-Level Security) is not in the scope of this guide.

A targeted policy includes a list of processes that will be protected (or confined) by SELinux, and anything not targeted will be unconfined and will use the Discretionary Access Control (DAC) model. Almost all processes listening on the network (such as httpd, sshd) are confined by SELinux. By confining the process, if it is compromised by an attacker then it greatly reduces what they can do.

Before SELinux is enabled, packages will need to be installed and LAMP and WordPress will be configured. SELinux will not be configured until the very end. Reasons for that will become clear later.

Part 1: Deploying a new virtual private server (VPS)

When you have a brand new virtual machine up and running with CentOS 7, first thing to do is make sure it’s up to date. You will initially need access to your virtual machine using the console (not SSH).

First, login with the console as root and apply the latest updates:

# yum check-update # yum update -y

Securing Access

Adding a new user

Root access via SSH will be disabled, and a standard user account will be used for administering the host. Whenever root privileges are required, sudo will be used. In this example, a user called Fred will be added. Fred is the sysadmin.

# useradd fred # passwd fred # visudo (this will open the sudoers file /etc/sudoers)

1) Look for the line root ALL=(ALL) ALL and add the new user account below it. This will allow the new user account to have sudo privileges.

fred ALL=(ALL) ALL

2) Save the file (:wq)

Disable Root SSH Access

Edit /etc/ssh/sshd_config and edit the following:

PermitRootLogin no
MaxAuthTries 3

Now logout, and log back in as the new user. Use sudo -i to login with root privileges if needed.

Configuring SSH Key Based Authentication

By this stage you should be logged in with your new user account (not root). PuTTYgen will be used to generate an RSA key pair and configure PuTTY to access the server using the private key.

Generate the public/private key-pair

  1. Launch PuTTYgen make sure SSH-2 RSA is selected and 2048 bits.
  2. Copy the public key in the text box, which starts with ssh-rsa AAAA and save it in a text file somewhere safe (For example, public.key).
  3. Save the private key (For example, private.ppk) and don’t lose it!

Copy the public key to the server

In order for public/private key authentication to work, your server must have a copy of the public key in ~/.ssh/authorized_keys. ~/ denotes your home directory (/home/bob), and you may need to create the .ssh directory first. Just make sure both .ssh and the authorized_keys file is owned by the user being authenticated (not root). Also the permissions need to be set as follows, otherwise you will get an error: Server refused our key

# chmod 700 .ssh # chmod 644 authorized_keys

Note: If you still get the error, check that this file isn’t owned by root.

1) Paste the contents of the public key in to the file ~/.ssh/authorized_keys. If this directory or file doesn’t exist, create it.

The authorized_keys file should look something like this:

ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEA5KigO6JFR8AtaCULlxdZ7lSo3OpkUY3I2K
YzGR+BuGjEcJCgSn6OLYTUw2ygFs4VhOwZ5Pmiq9T2RskiK6/gMa0VVoFM18xUS9Emwdq3pxWSD
SQ0pZzGut0mhBix+h7xpu40oIalLE7JJSWsjvzacKEgjq06lRQgpqWEAtV3E+Ks5tqyJ5/3PiEn
LCQOCma9OXp/YbtS37/Xi15iSeXzfgSf+BnOPB+yh72xwPZfIIx8KL8cVaK9MkYeKdBPMwM5R1a
I0Ek8T/idginalL4m9j+QoD7ajnOnm4NvOoD//K7uozEXcBZGUiMFX17aAcY44hWM462zJQ/Ml/
y2sco4iQ== rsa-key-20170906

Load the private key into PuTTY

  1. Launch PuTTY and configure the hostname under the Session category.
  2. Click on Connection > Data and type the Auto-login username
  3. Click on Connection > SSH > Auth and Browse to the location of the private key.
  4. Click on Session and save your configuration.

Now when you launch PuTTY it will present the private key, and this will match the corresponding public key on the server. Only you will hold a copy of the private key.

Disable SSH Password Authentication

This step is optional but strongly recommended. If you disable SSH password based authentication then you can only access SSH with the private key you configured in the last step. We still need passwords in order for sudo to work, but this step will only access certificate based authentication when you SSH to the host.

Edit /etc/ssh/sshd_config and set the following:

PasswordAuthentication no

Note: You’ll need to configure WordPress to use authentication keys if you disable SSH password authentication. This requires both the public and private key to be installed on the WordPress server which I think is a terrible idea. One option is to temporarily enable password authentication for SSH for when WordPress needs it. Installing Core Packages

Now it is time to install the core packages needed to get the secure server up and running. Wget is later required to download the RPMs for the Remi repository, and it is a useful tool to have on the host anyway. Later in the guide NTP will be configured for time synchronization. Iptables is my preferred firewall, and I suggest you follow my Essential Linux Skills with CentOS 7 Guide, which I will use here. Finally, yum-cron will be used to make sure the CentOS host is updated regularly, and the EPEL repository will be used.

Core Packages

# yum install -y wget ntp gcc net-tools iptables-services yum-cron sshfs epel-release

SMTP (Optional)

Next install Sendmail or Postfix. This is required if you want WordPress to send email notifications, or if you are using a website contact form. Since yum-cron is installed already, it can also be configured to send email notifications.

# yum install -y postfix

LAMP Stack

Next is the rest of the LAMP stack, which includes Apache, OpenSSL, PHP, and other associated components.

# yum install -y httpd mariadb-server mariadb openssl mod_ssl libstdc++ libssh2 libssh2-devel libssh2-docs libssh2 libssh2-devel

Installing the Remi Repository

CentOS 7 doesn’t install the latest version of PHP from the CentOS/RHEL or EPEL repositories. CentOS 7.3.1611 will install PHP version 5.4.16, not PHP 7.1 which is the latest version at the time of writing this.

The solution to this is to use the Remi repository.

First install the repo:

# wget https://rpms.remirepo.net/enterprise/remi-release-7.rpm # rpm -Uvh remi-release-7.rpm

Next, configure the repository and set the first entry to enabled=1. If you want to use PHP 5.6 and not the very latest (PHP 7.1) then set enabled=1 in the [remi-php56] section.

# vi /etc/yum.repos.d/remi.repo

[remi]
name=Remi's RPM repository for Enterprise Linux 7 - $basearch
#baseurl=http://rpms.remirepo.net/enterprise/7/remi/$basearch/
#mirrorlist=https://rpms.remirepo.net/enterprise/7/remi/httpsmirror
mirrorlist=http://rpms.remirepo.net/enterprise/7/remi/mirror
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-remi

Installing PHP 7.1

If you want to install PHP 7.1, then edit remi-php71.repo and set enabled=1 (make sure 5.6 disabled in the remi.repo as described in the previous step):

# vi /etc/yum.repos.d/remi-php71.repo

[remi-php71]
name=Remi's PHP 7.1 RPM repository for Enterprise Linux 7 - $basearch
#baseurl=http://rpms.remirepo.net/enterprise/7/php71/$basearch/
#mirrorlist=https://rpms.remirepo.net/enterprise/7/php71/httpsmirror
mirrorlist=http://rpms.remirepo.net/enterprise/7/php71/mirror
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-remi
Install PHP and use Yum to update the server:

# yum install php php-pecl-ssh2 gcc php-devel php-pear php php-gd php-mysql php-mcrypt php-mbstring # yum update -y

Check which version of PHP is installed:

# php -v

PHP 7.1.9 (cli) (built: Aug 30 2017 20:06:08) ( NTS )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2017 Zend Technologies

You should now have the latest version from the branch you selected.

Basic Server Configuration

Configuring yum-cron for automatic updates

Enable the yum-cron service to make sure it starts automatically.

# systemctl enable yum-cron

Edit the yum-cron.conf file:

# vi /etc/yum/yum-cron.conf

Configure the following lines:

update_cmd = security
apply_updates = yes

This is what will happen daily. You can optionally configure /etc/yum/yum-cron-hourly.conf, which as the name suggests is run every hour. By default the hourly configuration won’t download or apply anything.

Start yum-cron: # systemctl start yum-cron

Logging:

/var/log/yum.log contains a list of all installed packages.

NTP

Edit vi /etc/ntp.conf and configure NTP servers, for example:

server 0.centos.pool.ntp.org iburst
server 1.centos.pool.ntp.org iburst
server 2.centos.pool.ntp.org iburst
server 3.centos.pool.ntp.org iburst
Next enable NTPD and start the serice.

# systemctl enable ntpd # systemctl start ntpd

Set the correct timezone for your region:

# timedatectl list-timezones # timedatectl set-timezone 'America/New_York'

You can check the time and date using the date command.

Part 2: IPtables

One of the first measures is to disable root SSH access, change the SSH port to something other than the default (port 22), and set a maximum number of authentication attempts in order to minimize the likelihood of a brute force attack. Once you have done that then go ahead and configure IPtables.

I have already covered IPtables extensively in my previous blog Essential Linux Skills with CentOS 7 – Secure Firewall with iptables so I strongly recommend reading that first.

As a quick reference (TLDR) then here is a sample IPtables ruleset for web servers:

If you want to clear the current rules, use iptables -F to flush. When you’re done, remember to save and restart.

# Drop NULL packets
iptables -A INPUT -p tcp --tcp-flags ALL NONE -j DROP

# Block syn flood attack
iptables -A INPUT -p tcp ! --syn -m state --state NEW -j DROP

# Block XMAS packets
iptables -A INPUT -p tcp --tcp-flags ALL ALL -j DROP

# SSH Rate limit new connections (drop if more than 3 attempts in 60 seconds) and allow only established SSH connections
iptables -A INPUT -i eth0 -p tcp --dport 9922 -m state --state NEW -m recent --set --name SSH
iptables -A INPUT -i eth0 -p tcp --dport 9922 -m state --state NEW -m recent --update --seconds 300 --hitcount 4 --rttl --name SSH -j DROP
iptables -A INPUT -i eth0 -p tcp --dport 9922 -m state --state NEW,ESTABLISHED -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --sport 9922 -m state --state ESTABLISHED -j ACCEPT

# Allow DNS Queries
iptables -A INPUT -i eth0 -p udp --sport 53 -m state --state ESTABLISHED -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --dport 53 -m state --state NEW,ESTABLISHED -j ACCEPT

# Allow NTP
iptables -A OUTPUT -o eth0 -p udp --dport 123 -m state --state NEW,ESTABLISHED -j ACCEPT
iptables -A INPUT -i eth0 -p udp --sport 123 -m state --state ESTABLISHED -j ACCEPT

# Web Server (HTTP/HTTPS)
iptables -A INPUT -i eth0 -p tcp --dport 80 -m state --state NEW,ESTABLISHED -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --dport 443 -m state --state NEW,ESTABLISHED -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --sport 80 -m state --state ESTABLISHED -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --sport 443 -m state --state ESTABLISHED -j ACCEPT

# Web Browsing
iptables -A INPUT -i eth0 -p tcp --sport 80 -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --sport 443 -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --dport 80 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT

# Allow Inbound/Outbound to Localhost
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT

#Allow SMTP outbound (E.g Sendmail)
iptables -A INPUT -i eth0 -p tcp --sport 25 -m state --state ESTABLISHED -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --dport 25 -m state --state NEW,ESTABLISHED -j ACCEPT

# Log all dropped packets
iptables -N LOGINPUT
iptables -N LOGOUTPUT
iptables -A INPUT -j LOGINPUT
iptables -A OUTPUT -j LOGOUTPUT
iptables -A LOGINPUT -m limit --limit 4/min -j LOG --log-prefix "DROP INPUT: " --log-level 4
iptables -A LOGOUTPUT -m limit --limit 4/min -j LOG --log-prefix "DROP OUTPUT: " --log-level 4

# Set policies to drop everything else
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP

Save and then restart:
# iptables-save > /etc/sysconfig/iptables
# systemctl restart iptables

TIP: I like to keep a copy of my IPtables rules handy, without the comments so when I need to make changes I can simply flush the rules (iptables -F), paste, and then save.

Once you have completed your IPtables configuration, come back to continue with part 3 below.

Installing Fail2Ban

Fail2ban works by dynamically adding IP addresses to the firewall that have failed a certain number of login attempts. It’s very easy to install and configure.

# yum install -y fail2ban # systemctl enable fail2ban

Create a basic configuration:

# vi /etc/fail2ban/jail.local

[DEFAULT]
# Set a 1 hour ban
bantime = 3600

# Override /etc/fail2ban/jail.d/00-firewalld.conf:
banaction = iptables-multiport

[sshd]
enabled = true

Note: Digital Ocean have a guide on Fail2ban, which I’ve used to take the sample above.

Part 3: MariaDB (MySQL)

MariaDB (which replaces MySQL) has already been installed as part of the core package installation. In order to start at boot time, the service needs to be enabled.

# systemctl enable mariadb # systemctl start mariadb

Before proceeding, MySQL (MariaDB) needs to be secured. Using mysql_secure_installation, the implementation of MySQL is hardened for production use. You can do this later since it won’t delete any existing databases, but I recommend you do this now.

# mysql_secure_installation

There won’t be a password set since MariaDB is a fresh installation.

Type mysql -u root -p and hit enter. You should see the following:

Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 5
Server version: 5.5.52-MariaDB MariaDB Server

Copyright (c) 2000, 2016, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]>

You can list existing databases using the following SQL query:

SHOW DATABASES;

1) First set the root password for MySQL:

USE MYSQL;
UPDATE USER SET PASSWORD=PASSWORD("password") WHERE USER='root';
FLUSH PRIVILEGES;

2) Next create a database for WordPress

CREATE DATABASE cloudwire; (Change the database name to whatever you want, this will identify your WordPress database)

3) Create database user

CREATE USER 'wireyuser'@'localhost' IDENTIFIED BY 'c0mpl3xP$$SSSSw000rrrdddd'; (Change username and password to your own!)

4) Grant privileges to WordPress database

GRANT ALL PRIVILEGES ON cloudwire.* TO 'wireyuser'@'localhost';
FLUSH PRIVILEGES;
exit

Note: If you have multiple databases, just repeat from step 2.

Part 4: Migrating From Another VPS Host (Optional)

If you already have another WordPress installation you wish to migrate, follow these steps to move your files and MySQL database to the new server. If you are starting from scratch then feel free to skip this section and move to Part 4.

1) Backup your existing WordPress database

# mysqldump -u root -p DB_NAME > /home/fred/wordpress.sql

Next, copy the exported database (.sql file) to your new server.

Tip: Use WinSCP to copy files using SSH. You can use your SSH private key in WinSCP.

2) Import the WordPress database into MySQL on your new server

# mysql -u root -p DB_NAME < /home/fred/wordpress.sql

Transferring files from another VPS host

There are a few ways of doing this. I used to use sshfs to mount the SSH file transfer, but rsync can establish an SSH file transfer all in one hit. This is the fastest method in my opinion.

First, using WinSCP, make sure you can access the other VPS host and see all of the files required for the transfer. Using rsync everything will be copied to the new VPS.

Note: You may need to allow SSH in IPtables from your server.

Transfer the files using rsync

# rsync --ignore-existing -prvh -e "ssh -p 22" fred@ip_address:/data/oldsitedir/ /data/.sitedir/

Part 5: Configuring LAMP (Linux, Apache, MariaDB/MySQL and PHP)

By this stage your have either created an empty database, or if you followed part 4, you have transferred a database from another host and have all of the files and directory structure in place.

If you are building from scratch then so far you have an empty database for WordPress, but Apache hasn’t been configured yet or a new directory structure created. Since this is a multi-site virtual host, Apache will use HTTP headers to determine which website directory to serve.

SSL (HTTPS) will be used for the fictitious blog (cloudwire.info), and since host headers only work with HTTP (not HTTPS), a unique IP address is required for each site listening on 443. In this example, one site (cloudwire.info) will be configured on HTTPS (443), so a single IP address allocated by the VPS provider will be used. Your VPS provider will be able to tell you how to add additional IP addresses if they are needed.

Directory Structures and Permissions

It is really up to you how to create your directory structure. I prefer to create a new hidden directory, such as /data/.sitehome/ and then create a directory for each site. For example, /data/.sitehome/cloudwire.info/. Being a hidden directory (staring with a .) doesn’t add that much in terms of security, but it just a practice I have got used to over the years.

Obviously you should think of a better naming convention than I have. The key here is each website owner will have a user account that is jailed to their website directory. This user account will also be used with WordPress for installing updates, plugins and themes. You should have something similar to the following:

/data/.sitehome/cloudwire.info/
— web
— private
— logs
— cgi-bin
/data/.sitehome/wordpress_site2/
— web
— private
— logs
— cgi-bin

Create your site directory structure:

# cd /data/.sitehome/
# mkdir cloudwire.info
# cd cloudwire.info
# mkdir logs private cgi-bin web

SFTP (SSH File Transfer)

This is the way to go. Forget FTP. SSH is already configured, so in order to use it effectively for file transfer (SFTP) chroot, or a jail, will be used to confine the user to their home directory.

For each site being hosted, add a site admin account for SFTP. SSH access will be disabled for each of these users. I like to use random usernames for each site that can’t be guessed very easily.

# useradd username -d /data/.sitehome/wordpress001/ (You will get a warning if you have created the directory, you can ignore this.)

If you have already added the user, you can simply modify the home directory using:

# usermod -m -d /data/.sitehome/ username

1) First add a group called ‘sshonly’

# groupadd sshonly

2) Add the user to be chrooted to that group. A new username will be created for the first website ‘cw001’ for the cloudwire.info site.

# usermod -aG sshonly cw001

3) Edit /etc/ssh/sshd_config and change the SubSystem line from:

Subsystem sftp /usr/libexec/openssh/sftp-server to: Subsystem sftp internal-sftp

Note: This doesn’t change SSH access.

4) Add the following to the end of sshd_config:

Match Group sshonly
ChrootDirectory %h
ForceCommand internal-sftp
X11Forwarding no
AllowTcpForwarding no

5) Restart SSHD

# systemctl restart sshd

6) Change the group of the root directory and all sub-directories to ‘sshonly’.

# chgrp sshonly /data/.sitehome/wordpress001/ -R

Note: If you need to modify an existing user account with a new home directory, use the following command: usermod -m -d /data/.sitehome/wordpress001/ cw001

Everything inside the website directory needs to be owned by the new user account (E.g. cw001). Don’t give them ownership of the website directory itself, just everything inside.

# chown cw001 * -R

Note: Steps 2-4 above ensures that the user cannot login to the host using SSH, but they can use SFTP.

Apache Configuration

If you have stuck with me this far into the guide then give yourself a pat on the back. No really, do it. Go and grab another coffee, change the Spotify playlist and let’s finish this beast of a web server!

1) Enable the http daemon.

# systemctl enable httpd

2) Edit httpd.conf # vi /etc/httpd/conf/httpd.conf and change the following line:

ServerName X.X.X.X:80 (Specify your servers IP address or hostname here)

3) Add the VirtualHost entry for the default site at the very bottom of httpd.conf. I call this the not-yet page. If anyone reaches the server with the IP address or it can’t be matched by an HTTP header, then it will display DocumentRoot as a default page.

# Load Default VirtualHost
<VirtualHost *:80>
     DocumentRoot /data/.sitedir/not-yet/web
<Directory /data/.sitedir/not-yet/web>
     AllowOverride None
     Require all granted
</Directory>
</VirtualHost>

4) Save the file (:wq)

# systemctl start httpd

Note: Make sure all of these directories exist and the paths are correct, otherwise HTTPD will fail to start.

At this stage if everything is configured correctly, when you start HTTPD, and browse to the IP address of your server you should be served the contents of /data/.sitehome/not-yet/webroot/, you should have index.html in there. I’ve got a standard ‘Not yet!’ page I’ve been using since the 1990s, and as I’m nostalgic I’ve decided to keep it all these years. Let’s try it out! If that works then proceed to adding the first site

Adding Our First Site (VirtualHost) for CloudWire.info

Each site needs a VirtualHost entry. So far, only the default page that is displayed for HTTP (TCP port 80) has been configured. I like to keep things tidy, so rather than adding multiple VirtualHost entries in httpd.conf, ‘Include’ entries will be used that point to a file in /etc/httpd/vhosts/. This is where the VirtualHost configuration file for each site will be stored.

For this example, I’ve just registered cloudwire.info and configured DNS to point to the IP address of the new server.

1) To start with, at the very bottom of httpd.conf add an ‘Include’ pointing to the VirtualHost file. Give the file a meaningful name, this is just an example below.

Include /etc/httpd/vhost/cloudwire.info.conf

2) Next, create the vhost file (# vi /etc/httpd/vhost/cloudwire.info.conf). The vhost directory won’t exist so you’ll need to create that.

<VirtualHost *:80>
ServerAdmin admin@cloudwire.info
DocumentRoot /data/.sitehome/cloudwire.info/webroot
ServerName www.cloudwire.info
ServerAlias cloudwire.info
ScriptAlias /cgi-bin/ /data/.sitehome/cloudwire.info/cgi-bin/
<Directory /data/.sitehome/cloudwire.info/web>
        Options FollowSymLinks
        AllowOverride All
        Require all granted
</Directory>
</VirtualHost>

3) Save the file (:wq) and restart httpd.

# systemctl restart httpd

Providing DNS is updated with the IP address of your server, you should now be able to browse to the new website and it will be displayed.

Configuring SSL

Configuring HTTPS for your website first requires an SSL certificate, and it really isn’t very difficult at all. I used GoDaddy and they have several options for SSL certificates. If you are concerned about costs then I recommend LetsEncrypt which is free. Typically, 3 files are required:

  • Certificate file (.crt)
  • Certificate private key (.key)
  • Intermediate certificates chain (bundle .crt)

You should place the certificate files outside of the web root directory, in this example they are stored in /data/.certificates/cloudwire.info/.

Open the file you created in the previous step (# vi /etc/httpd/vhost/cloudwire.info.conf) and add another VirtualHost entry as below, but changing it with your own values:

<VirtualHost XX.XX.XX.XX:443>
SSLEngine On
SSLCertificateFile /data/.certificates/cloudwire.info/cloudwire.crt
SSLCertificateKeyFile /data/.certificates/cloudwire.info/cloudwire.key
SSLCertificateChainFile /data/.certificates/cloudwire.info/gd_bundle-g2-g1.crt
ServerAdmin admin@cloudwire.info
DocumentRoot /data/.sitehome/cloudwire.info/webroot
ServerName www.cloudwire.info
ServerAlias cloudwire.info
ScriptAlias /cgi-bin/ /data/.sitehome/cloudwire.info/cgi-bin/
<Directory /data/.sitehome/cloudwire.info/web>
        Options FollowSymLinks
        AllowOverride All
        Require all granted
</Directory>
</VirtualHost>

Note: As you can see, this is why you will need an additional IP address for each site requiring SSL, since you need to specify the IP listening on port 443.

Part 6: Installing WordPress

While WordPress can be installed using yum, it will assume WordPress is running on a single site web server using /var/www/html for the website location. Since one or more WordPress sites using VirtualHost configuration in Apache will run on the server, wget will be used to grab the latest version of WordPress and install directly into our site directory.

1) Navigate to your home directory (sysadmin account)

# cd /home/fred

2) Download the latest WordPress binaries and extract the contents of the tarball.

# wget https://wordpress.org/latest.tar.gz # tar -xzvf latest.tar.gz

3) Copy the contents of the WordPress directory to the website directory (/data/.sitehome/clourwire.info/web).

# cp wordpress/* /data/.sitehome/clourwire.info/web/ -R

4) Move the sample configuration file up one level (/home/.sitehome/cloudwire.info/) and copy it new wp-config.php. Never place the config file in a publicly accessible location.

# mv wp-config-sample.php ../ # cd .. # cp wp-config-sample.php wp-config.php

5) Edit wp-config.php (vi wp-config.php) and configure the database name, username and password as created in Part 2. If you migrated your WordPress database from another VPS (Part 3), then enter the details of the WordPress database here.

define('DB_NAME', 'database_name_here');
define('DB_USER', 'username_here');
define('DB_PASSWORD', 'password_here');

6) Finally you should add unique keys that WordPress will use for authorization and encryption. There is actually an online salt generator which generates this configuration for you.

define('AUTH_KEY', 'put your unique phrase here');
define('SECURE_AUTH_KEY', 'put your unique phrase here');
define('LOGGED_IN_KEY', 'put your unique phrase here');
define('NONCE_KEY', 'put your unique phrase here');
define('AUTH_SALT', 'put your unique phrase here');
define('SECURE_AUTH_SALT', 'put your unique phrase here');
define('LOGGED_IN_SALT', 'put your unique phrase here');
define('NONCE_SALT', 'put your unique phrase here');

7) Navigate to your website URL (E.g. http://cloudwire.info) and you will be presented with a welcome page. You’ll need to enter some basic information and importantly create your WordPress username and password. When you are ready, select ‘Install WordPress’.

Congratulations, you have setup your first WordPress Site!

Securing WordPress

The security of WordPress probably justifies a blog post all by itself, but I can provide some pointers to help you lock it down enough that you’ll be safe from brute force attacks and limit the WordPress attack vector for vulnerabilities. Nothing is 100% secure. If there is a 0-day vulnerability in WordPress then fingers crossed IPtables, SELinux and keeping the server updated with yum-cron is enough.

I am no longer surprised when I take at look at other blogs and see how insecure they are. Here are my top 10 of security issues I would address immediately (in no particular order):

  1. Excessive use of plug-ins
  2. Out of date plug-ins
  3. Theme vulnerabilities
  4. WordPress is out of date
  5. Weak admin password
  6. No 2-Factor authentication (Use Google Authenticator, it’s free!)
  7. Admin username revealed in blog posts (don’t post blogs with your admin account!)
  8. Using HTTP (insecure) instead of HTTPS
  9. Access to wp-admin unprotected
  10. Using FTP instead of more secure SSH (SFTP) for file transfers

It can be very tempting when you first start using WordPress to try out shiny new plug-ins and themes. These can be open to security vulnerabilities and provide an easy way in for somebody wanting to hack your site. A bit of vigilance can go a long way. Research known bugs and security flaws with any themes or plug-ins you download, and don’t get too carried away. The more plug-ins you have installed the more chance you’ll be bitten.

Most of these security best practices are just common sense and the others are well documented elsewhere. For the purposes of this guide, I will focus on the last three (8, 9 and 10).

Redirecting HTTP to HTTPS using the rewrite module

Since SSL certificates have already been installed (in part 4), an SSL redirect needs to be created in .htaccess. If you go to http:// it will automatically redirect to https://.

  1. Edit ‘.htaccess’ in the web root directory (vi /data/.sitehome/cloudwire.info/web/.htaccess). This file should already exist for WordPress, if not create it.
  2. Add the following, replacing it with your own URL:
# SSL Redirect
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{SERVER_PORT} 80
RewriteRule ^(.*)$ https://www.cloudwire.info/$1 [R,L]
</IfModule>

Protecting WordPress Admin (wp-admin) with .htaccess

Some say that obfuscation doesn’t provide any security benefits, so there isn’t any real advantage to renaming wp-login.php to something else. Since I don’t trust too many plug-ins, using a plug-in to rename it seems like a double-edged sword. Instead, I prefer to use .htaccess to prevent access from unauthorised sources to wp-login.php and the wp-admin directory.

  1. Create .htaccess in your wp-admin directory (vi /data/.sitehome/cloudwire.info/web/wp-admin/.htaccess)
  2. Add the following, replacing X.X.X.X with your IP address(es). Additional IP addresses can be white-listed on new lines.
Order Deny,Allow
Deny from all
Allow from X.X.X.X
Allow from X.X.X.X

Note: If your IP address changes and you get locked out, you will need to SSH into your server and update this.

Protecting wp-login.php with .htaccess As an additional measure, we can do the same to restrict access to the wp-login.php file which resides in your web root directory.

  1. Edit ‘.htaccess’ in the web root directory (vi /data/.sitehome/cloudwire.info/web/.htaccess). This file should already exist for WordPress, if not create it.
  2. Add the following, replacing X.X.X.X with your IP address.
<Files wp-login.php>
    order deny,allow
    Deny from all
    Allow from X.X.X.X
</Files>

Configuring SFTP for WordPress

In part 5 you have already learned how to use SSH for SFTP file transfers and use ‘chroot’ to restrict access to the site directory. WordPress supports FTP, FTPS (SSL) and SSH2 connections. When you update WordPress or install a plug-in, you will be presented with the following:

  1. Change the hostname to the IP address or hostname of your server, and remember to specify the SSH port as this is not using the default (cloudwire.info:9922)
  2. Choose SSH2 as the connection type
  3. Enter the username and password for your site admin (See part 5)
  4. Leave Authentication Keys blank (see note)

Note: As I mentioned in part 1, you’ll need to configure WordPress to use authentication keys if you disable SSH password authentication. This requires both the public and private key to be installed on the WordPress server which I think is a terrible idea. One option is to temporarily enable password authentication for SSH for when WordPress needs it.

Part 7: SELinux

If you have made it this far, hopefully everything is working as it should. You can create multiple sites, host WordPress, transfer files using SFTP and the host is secure with IPtables.

There is a reason I have saved SELinux until last. Grab another coffee, and while you are up and about get a notepad, a pen, turn your phone to silent and get comfortable. No really, this next step is where most folk abandon SELinux and set it back to ‘disabled’. You wouldn’t do that now, would you? Stick with me!

Note: If you use Linode, by default their kernel doesn’t include SElinux. This is easy enough to fix, just follow their guide on using a distribution supplied kernel.

Setting Permissive Mode

Don’t worry, SELinux won’t enforce anything just yet!

1) First, install required packages for SELinux:

yum install -y policycoreutils policycoreutils-python selinux-policy selinux-policy-targeted libselinux-utils setroubleshoot-server setools setools-console mcstrans

2) Next SELinux to ‘permissive’ mode and reboot:

Before actually enforcing SELinux policies, it is important to test SELinux with ‘permissive’ mode first. SELinux will log activity to /var/log/audit/audit.log, starting with “SELinux is preventing”, which will be useful to troubleshoot any processes, files or directories that would otherwise be restricted.

    # vi /etc/sysconfig/selinux
    Change SELinux to ‘permissive’ and then reboot.
    SELINUX=permissive (:wq)
    # reboot

How SELinux Works

Before going any further, I am going to explain how SELinux works as simply as I can. It is notorious for being a complicated topic, but it really shouldn’t be.

The configuration for SELinux is contained in /etc/sysconfig/selinux, and you can see whether it is running by using the sestatus or getenforce command. If you type sestatus you’ll see it will either be set to ‘disabled’, ‘enforced’, or ‘permissive’. By setting SELinux to permissive, which is the first step, it won’t actually enforce any policies but it will log them. This is really useful before switching it on, as it will allow you to see what it would otherwise restrict.

SELinux Context and Labels

To understand how SELinux actually works, it is important to understand the concept of SELinux context and labels. Labelling allows files, processes, sockets, directories, TCP and UDP ports and many others, to be labelled with a SELinux context. At the time of writing this, I checked and there are 4729 types listed. Don’t worry, you won’t be expected to memorize them all!

You can use seinfo -t to list all SELinux context types, but if you combine it with grep you can narrow the search. Give this a try, seinfo -t | grep httpd_sys. This will show you all of the context types starting with httpd_sys. This will become useful later on when troubleshooting SELinux.

Imagine Apache, hosting your brand new online store, is actually similar to a real store front. Visitors can see your website by opening the door (port 80 / 443) and taking a look at what is inside. Great! Now imagine a guardian inside the store called SELinux. They expect visitors to open the door on ports 80 and 443, and all is well. But the little monster has just stopped visitors looking at your product. Why!? Well, you didn’t label your product (files) with the correct label so SELinux prevented them from reading it.

Forbidden
You don't have permission to access / on this server.

The SELinux context for files and directories are labelled with extended attributes on the file system, and everything else is managed by the kernel. Since this is a web server running Apache, SELinux will expect a label that give Apache access to /var/www/ directory and all files within it. Take a look at /var/www/ using ls -aZ. This will list all of the files in the working directory.

# cd /var/www # ls -aZ

Note: Labels use the following format: user:role:type:level, but for targeted mode, and the scope of this guide, I will ignore the others and just focus on type.

Using ls -aZ, you can see that the type for the html directory is set to httpd_sys_content_t:

drwxr-xr-x. root root system_u:object_r:httpd_sys_content_t:s0 html

In this example, user:role:type:level for the html directory is:

  • User: system_u
  • Role: object_r
  • Type: httpd_sys_content_t
  • Level: s0

SELinux recognizes the type label and allows Apache (httpd) to read the file.

But wait! This server is going to be configured with multiple websites and will not use the default /var/www/html location. Take a look at the SELinux context for the new location /data/.sitedir/.

# cd /data/.sitedir/ # ls -aZ

The type is set to default_t, and if Apache tries to read files from this location, SELinux will prevent it since it hasn’t been labelled correctly. You can see why SELinux can be a pain, but also very powerful. I will show you how to set httpd_sys_content_t on the new web directory location in a moment.

Booleans

A Boolean, named after George Boole (pictured left), is a true or false value. There are 301 Boolean settings at the time of writing this, and similar to using seinfo -t to list types, getsebool -a can be used to list them all. Boolean values can be set using the setsebool -P command (-P means persistent so it will be written to disk), and you can specifiy a ‘1’ or ‘0’ (on / off).

To help you understand SELinux Booleans, it may help list the ones relevant to an Apache server. Using semanage boolean -l | grep httpd, Boolean settings that contain the word ‘httpd’ and listed. This will list around 42, along with their description. Here is a sample:

httpd_enable_cgi (on , on) Allow httpd to enable cgi
httpd_graceful_shutdown (on , on) Allow httpd to graceful shutdown
httpd_builtin_scripting (on , on) Allow httpd to builtin scripting
httpd_unified (off , off) Allow httpd to unified
httpd_can_sendmail (off , off) Allow httpd to can sendmail
httpd_can_network_connect (off , off) Allow httpd to network connect

There are three that stand out:

httpd_unified

httpd_unified is off by default with CentOS/RHEL 7. This means that SELinux will require the file/directory type label httpd_sys_rw_content_t if Apache is required to write to that directory. For WordPress this would include the wp-uploads directory. With previous versions (CentOS 6), httpd_unified is on, and Apache can read, write and execute on files/directories with the httpd_sys_content_t label.

httpd_can_sendmail

Another Boolean httpd_can_sendmail says that it allows Apache (httpd) to sendmail. WordPress often requires this to send emails for new users, comments, and so on.

httpd_can_network_connect

By default this is off. Setting it to ‘on’ will allow Apache (httpd) to connect to the network. This is required if scripts running under Apache need to connect to remote services (E.g RSS or wget).

Note: To see which httpd Booleans are on, use semanage boolean -l | grep httpd | grep -v off. Grep -v inverts the results. Since the list contains the word ‘on’ for the default setting, I can’t use that so I’m saying anything that doesn’t contain the word ‘off’. Configuring SELinux to Play Nicely with Apache and WordPress

Apache is running and a few things have changed so it’s time to tell SELinux about these changes.

Step 1: Setting the Correct SELinux Context for the Web Directories

Remember I said that your website directories need httpd_sys_content_t? Let’s configure that now on the directory containing all website directories:

# semanage fcontext -a -t httpd_sys_content_t "/data/.sitehome(/.*)?" # restorecon -Rv /data/.sitehome

semange is another useful command that can be used to read and configure settings on network ports, interfaces, SELinux modules, file context and booleans. restorecon -Rv will set this security context recursively (-R) and will output any changes to the type (-v).

Step 2: Allow SSH on non-default port

Another change is the default SSH port from 22 to 9922. Again, using semanage the new port can be specified.

# semanage port -a -t ssh_port_t -p tcp 9922

Step 3: Allow Apache to use Sendmail

This time the setsebool command will be used to set the boolean for httpd_can_sendmail to 1. This will allow Apache (httpd) to send email.

# setsebool ­P httpd_can_sendmail 1

Step 4 (Optional): Allow Apache to Read & Write directories with httpd_sys_content file types

As mentioned previously, the httpd_unified Boolean is turned off by default with CentOS/RHEL 7. This does strengthen security, as any files and directories that need to be writable by Apache will require the httpd_sys_rw_content_t context. However, for WordPress this can prevent users from uploading files or installing plug-ins. If you decide to enable this and allow read, write and execute, then use the following command:

# setsebool -P httpd_unified 1

Troubleshooting SELinux

First, check the status of SELinux and make sure it is set to permissive which you did at the beginning of part 7.

# sestatus

Check that SELinux is enabled, the policy is ‘targeted’ and the mode is set to ‘permissive’:

SELinux status: enabled
Loaded policy name: targeted
Current mode: permissive

When troubleshooting SELinux it can be useful to clear the audit log and then rebooting. This will make it easier to read the logs and not get confused with older entries while you are troubleshooting in permissive mode.

1) Clear the audit.log and reboot

# > /var/log/audit/audit.log # reboot

2) Use sealert command to check for issues in audit.log

# sealert -a /var/log/audit/audit.log

Note: You can run a summary of audit log reports using aureport -a. This will provide a summary of reports from the audit log (/var/log/audit/audit.log). You can check the logs for today using aureport -a -ts today.

When you have run the sealert command, at the top of the report you’ll see something similar to:

100% done
found 7 alerts in /var/log/audit/audit.log

The output may look really lengthy at first, but don’t fret! Once you start looking at each report you’ll see it is actually very useful. It will start with “SELinux is preventing”, and provide a confidence level for each suggested fix. I will list some common issues, along with the steps you will need to take to resolve the issue.

Issue 1: SELinux is preventing audispd from getattr access on the file /etc/ld.so.cache.

This provided an easy fix, detailed right there in the report:

If you want to fix the label. /etc/ld.so.cache default label should be ld_so_cache_t. Then you can run restorecon. Do:

# /sbin/restorecon -v /etc/ld.so.cache

Issue 2: SELinux is preventing audispd from open access on the file /etc/ld.so.cache.

This is tied to the first issue, and the same fix is suggested so will move on to the next one.

Issue 3: SELinux is preventing /usr/sbin/sedispatch from execute access on the file sedispatch.

This had a 100% confidence level, with the suggested fix below:

# ausearch -c '/usr/sbin/sedispatch' --raw | audit2allow -M my-sedispatch # semodule -i my-sedispatch.pp

Issue 4: SELinux is preventing /usr/sbin/httpd from name_connect access on the tcp_socket port 9922.)

This is actually required since FTP is not used to update WordPress or install plugins, and Apache needs to connect on SSH.

# setsebool httpd_can_network_connect=1

Rinse and repeat. You may need to do this a few times, implementing the fix, clearing the log, rebooting. Once you are satisfied that there are no more issues being reported by SELinux, you can now set it to ‘enabled’!

# vi /etc/sysconfig/selinux

Change SELinux to ‘enforced’ and then reboot.

SELINUX=enforced (:wq)

# reboot

Once the server boots, log in and check getenforce which should report ‘Enforcing’.

Conclusion

My aim with this guide is to get you setup with a secure web server with CentOS 7, running WordPress for multiple websites. I’ve taken no shortcuts in regards to security, and while I agree that usability is important, I hope you can see that enabling SELinux doesn’t mean ‘unusable’ any longer.

Key Takeaways: By following this guide you now have:

  • A basic understanding of SELinux and have it set to ‘enforcing’ on your web server.
  • Learned how to use IPtables and use it to secure your server.
  • Host your blog in WordPress using HTTPS (SSL)
  • Host multiple websites using VirtualHost in Apache, and setup our new fictitious WordPress site, cloudwire.info!
  • Provided website admins secure SFTP file transfer, restricting them to their own site directory.
  • Learned how to use yum-cron to maintain the host with automatic updates.

Additional Resources