Skip to main content

Securing wordpress admin with SSL – The hard way

I have since moved away from WordPress back to a static generator, so everything below can be considered an outdated information.

Having performed initial migration from Octopress to a hosted WordPress installation I found that I became in charge of login security. The hosting provider I am currently using provides neither dedicated IP addresses nor shared SSL certificates (update: it does provide a dedicated IP address upon request, but requires a support ticket and a small fee for that) it means that my WordPress password and session information is transmitted in clear text. Of course, when I am at home it’s not a big deal, only the ISP may intercept it. But when I am anywhere else, my devices can connect to some unprotected Wi-Fi access point and accidentally surrender my credentials to somebody running tcpdump nearby…

/galleries/dropbox/wordpress-tcpdump-624x160.jpg

Update: Since I found out that the hosting provider actually provides a dedicated IP upon request for a quite small fee (25₴ at the moment, around $3), the value of the article has decreased. However, it is perfectly usable when nginx and wordpress are installed on the same host/datacenter but nginx is fronting apache.


I have a VPS which is used for occasional VPN access (using OpenVPN). One option was to always start OpenVPN client before accessing the blog. I could easily forget to do that. My browser may open the tab where I am already logged in and then my session cookie can be easily intercepted. Also I can’t use this VPS for WordPress hosting itself because it has only 512Mb of RAM and bringing up a SQL server, HTTP server (as well as keeping existing mail server setup, bip and OpenVPN running) may cause something to fall outside the available memory. Having said that, it looks like it is possible to run a WordPress blog in a similar configuration with whopping 10 million hits a day.

I decided to make this VPS act as a proxy server, which listens to HTTP and HTTPS traffic and forwards the requests to the hosting provider. In this case everything will pass through the VPS.

WordPress already has the options to force https scheme on wp-login.php and wp-admin directory.

Since this VPS is a low-profile one, a slim HTTP server is required. While I am already familiar with Lighttpd, I decided to try nginx.

/galleries/dropbox/startssl-no-kidding.png

You will need a SSL certificate. StartSSL is a good place to get one for free and it is recognized by major browsers out of the box. Approving new accounts takes time so I decided to use the old proven self signed certificate first and then change it to a proper one.

Here I would like to remind how important StartSSL client certificate is. You get the client certificate upon account approval and then use it to log into StartSSL control panel. This certificate will eventually expire and if you fail to renew it, you will never be able to get back to your account. Don’t repeat my mistake – when StartSSL tells you the certificate is about to expire, then get a new one immediately.

The VPS is running Ubuntu 10.04 LTS and (naturally) nginx version is quite old (0.7.65, February 2010). Nginx team has a PPA that builds packages for 10.04 too, so installation was simple:

sudo apt-add-repository ppa:nginx/stable
sudo apt-get update
sudo apt-get install nginx

Nginx comes with a proxy cache module. At first I started with a simple configuration that cached everything, then I found that the pages that were rendered for some commenter were also served to other people disclosing the previous commenter’s email. This is why preventing serving the cached content to the owners of our cookies is so important.

nic.ua also uses nginx as a proxy in front of apache. Therefore it is overwriting the X-Real-IP header which my proxy is setting. In order to work around that I used X-Upstream-Real-IP header, which I use in .htaccess to set UPSTREAM_PROXY_TRUSTED variable. The latter is then checked in wp-config.php to decide whether X-Forwarded-Proto and X-Upstream-Real-IP headers can be trusted. These X- headers can easily be forged and X-Forwarded-Proto should only be used if it comes from the trusted sources, or else the MITM attacks become extremely easy.

/etc/nginx/sites-available/rtg.in.ua:

sites-available-rtg-in-ua.apache (Source)

server {
  listen [::]:80;
  server_name www.rtg.in.ua blog.rtg.in.ua;
  rewrite ^ http://rtg.in.ua$request_uri? permanent;
}

upstream wordpress {
  server 91.209.206.62:80;
}

proxy_cache cache;

server {
  include proxy_params;

  # sites-available/default should also have listen directive changed
  # for IPv6 & IPv4 to work to
  #   listen   [::]:80 default_server;
  listen [::]:80;
  server_name  rtg.in.ua;

  access_log  /var/log/nginx/rtg-in-ua.access.log;

  location / {
    proxy_pass http://wordpress;
    proxy_cache_valid 404 1m;

    # we should not serve cached version if we have one of these cookies
    if ($http_cookie ~* "wordpress|comment_author|wp-postpass_") {
      set $bypass_cache 1;
    }

    proxy_cache_bypass $bypass_cache;
  }

  # These are completely static and can be shared between
  # HTTP and HTTPS virtual hosts
  location ~* \.(jpg|jpeg|png|gif|css|js|mp3|wav|swf|ogg|txt) {
    proxy_cache_key $host$request_uri;
    proxy_cache_valid 200 120m;
    # 30 days
    expires 2592000;
    proxy_pass http://wordpress;
  }

  location ~* (^|\/)feed\/ {
    proxy_cache_valid 200 60m;
    proxy_pass http://wordpress;
  }

}

# HTTPS server
# We allow all interaction to happen over HTTPS too.
server {
  listen   [::]:443;
  server_name  rtg.in.ua;
  include proxy_params;

  ssl  on;
  ssl_certificate     /etc/ssl/certs/rtg-in-ua.crt;
  ssl_certificate_key /etc/ssl/private/server.key;

  ssl_session_timeout  5m;

  ssl_protocols  SSLv2 SSLv3 TLSv1;
  ssl_ciphers  ALL:!ADH:!EXPORT56:RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv2:+EXP;
  ssl_prefer_server_ciphers   on;

  access_log  /var/log/nginx/rtg-in-ua.access.log;

  location / {
    proxy_cache cache;
    proxy_cache_valid 404 1m;

    if ($http_cookie ~* "wordpress|comment_author|wp-postpass_") {
       set $bypass_cache 1;
    }

    proxy_cache_bypass $bypass_cache;
    proxy_pass  http://wordpress;
  }

  location ~* ^(wp-admin|wp-login) {
    proxy_pass  http://wordpress;
  }

  location ~* \.(jpg|jpeg|png|gif|css|js|mp3|wav|swf|ogg|txt) {
    proxy_cache_key $host$request_uri;
    proxy_cache_valid 200 120m;
    expires 2592000;
    proxy_pass http://wordpress;
  }

  location ~* (^|\/)feed\/ {
    proxy_cache_valid 200 60m;
    proxy_cache cache;
    proxy_pass http://wordpress;
  }
}

/etc/nginx/conf.d/proxy-cache.conf:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=cache:30m max_size=1G;
proxy_temp_path /var/lib/nginx/proxy 1 2;
proxy_cache_key $scheme$host$request_uri;

/etc/nginx/proxy_params:

proxy_set_header Host $host;
# This gets overwritten:
proxy_set_header X-Real-IP $remote_addr;
# This is our own header:
proxy_set_header X-Upstream-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

While it looks like I am just re-setting proxy_cache_key to the default value, nginx is actually using backend URL by default (contrary to the docs!), thus you will be serving a document full of generated HTTP links on a HTTPS connection and vice versa.

I decided to create a new directory for the cache:

sudo mkdir -m 0700 /var/cache/nginx
sudo chown www-data:root /var/cache/nginx

Enabled the virtual host and restarted the server:

sudo ln -s /etc/nginx/sites-available/rtg.in.ua /etc/nginx/sites-enabled
sudo /etc/init.d/nginx restart

Nginx is configured, the proxy server is ready to serve.

Now back to the WordPress installation, setting the UPSTREAM_PROXY_TRUSTED variable:

.htaccess:

# Trusted IPs
RewriteCond %{REMOTE_ADDR} =173.212.238.58 [OR]
RewriteCond %{REMOTE_ADDR} =2607:f878:1:654:0:25:3078:1 [OR]
RewriteCond %{REMOTE_ADDR} =178.159.236.29 [OR]
RewriteCond %{REMOTE_ADDR} =2a01:d0:801a:1::14
RewriteRule . - [E=UPSTREAM_PROXY_TRUSTED:1]

And now we need WordPress to redirect all the login and admin activities to https:// urls as well as trust X-Forwarded-Proto HTTP header by adding the following to wp-config.php:

...
/*
* http://codex.wordpress.org/Administration_Over_SSL
*/
define('FORCE_SSL_ADMIN', true);
define('FORCE_SSL_LOGIN', true);

/*
* This variable is set in .htaccess in case
* REMOTE_ADDR is one of our upstream proxies
*/
if ($_ENV['UPSTREAM_PROXY_TRUSTED']) {

    if ($_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https')
    $_SERVER['HTTPS']='on';

    /*
    * X-Real-IP is overwritten by nginx proxy @ nic.ua
    * so we are using our own header.
    */
    $x_real_ip = $_SERVER['HTTP_X_UPSTREAM_REAL_IP'];

    // IPv4 in IPv6 notation
    if (substr($x_real_ip, 0, 7) == "::ffff:")
        $x_real_ip = substr($x_real_ip, 7);

    $_SERVER['REMOTE_ADDR'] = $x_real_ip;

}

/* That's all, stop editing! Happy blogging. */
...

That’s pretty much it. Self-signed, but encrypted:

/galleries/dropbox/wp-login-https-cropped.png

Chromium SSL popup

This approach has several flaws. First of all, I am increasing the number of points of failure. Since the data has to travel from Ukraine to the US even when request comes from Ukraine, the latency is increased a bit. CDN usage also gets more complicated (unless static files are served on a different domain). Neither of these issues is currently bothering as I am still in the process of migrating my content.

Please note that I am not a nginx guru, so if you see any error in the configuration, let me know.