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…
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.
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:
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:
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:
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.