How to Secure the Ghost Blogging Platform

2020-04-27

Note: For more simplicity, blakejarvis.com has been migrated to GitBooks.

The Ghost blogging platform has built-in security features such as Automatic SSL, brute force protection on the admin login, and SQLi prevention. This is a great start, and the platform is backed by a community that takes vulnerability reporting seriously. However, I wanted to add a few a few security enhancements that others should be aware of.

As explained in a previous post, this blog is hosted on AWS with three docker containers: a Ghost image, an Nginx proxy image, and a Lets Encrypt image. I'll be modifying the Nginx proxy image to add the following security enhancements:

  1. Restricting access to the Ghost admin portal to trusted IP address ranges

  2. Reducing unnecessary system version disclosure in HTTP response headers

  3. Preventing clickjacking with the X-Frame-Options header

Restricting Access to the Ghost Admin Portal

By default, Ghost exposes the admin portal to everyone with blog access. In this case, the portal is located at https://blakejarvis.com/ghost.

The Ghost software does not have an easy way restrict this admin portal to trusted IP address ranges, but this is easy to accomplish with nginx, which is already in my deployment. To edit the nginx files I entered the nginx-proxy docker container by running the following command which spawns an interactive (-i) TTY (-t) shell (bash) in my container (c979d3182d71). I then updated the apt repository and downloaded nano, a lightweight text editor since I need to edit configuration files.

user@blakejarvis
docker exec -it c979d3182d71 bash
apt-get update && apt-get install nano

Nginx configuration files are located at /etc/nginx/nginx.conf and /etc/nginx/conf.d/default.conf. The /etc/nginx/nginx.conf file is the main configuration file but includes the files in the conf.d directory with a include /etc/nginx/conf.d/*.conf; statement. Below is my /etc/nginx/nginx.conf file:

nginx.conf
user  nginx;
worker_processes  auto;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;
events {
    worker_connections  1024;
}
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    #tcp_nopush     on;
    keepalive_timeout  65;
    #gzip  on;
    include /etc/nginx/conf.d/*.conf;
}
daemon off;

No changes are required to be made in this file, but it is important to understand this is the master config file which 'includes' the others.

The Ghost admin portal can be restricted by editing the /etc/nginx/conf.d/default.conf file and adding location blocks. Location blocks tell the nginx server how to respond to certain Uniform Resource Identifier (URI) requests that meet the block's specified criteria. In this case, I am interested in telling the nginx server to respond differently to everyone requesting the /ghost/ URI, except for requests coming from trusted IP address ranges. Adding the location blocks below to the server block listening on port 443 can accomplish this (full /etc/nginx/conf.d/default.conf file located at bottom):

default.conf
location = /ghost/ { 
	if ($remote_addr != [trusted_IP_address]) {
		return 301 https://$host;
	}
	proxy_pass http://blakejarvis.com$request_uri;
}
location = /ghost/api/v3/admin/session {
	allow  [trusted_IP_address];
	deny all;
	proxy_pass http://blakejarvis.com$request_uri;
}
location /ghost/api {
	allow all;
	proxy_pass http://blakejarvis.com$request_uri;
}

Taking each of these blocks one by one:

  1. location = /ghost/ - An if statement is used to check if the requesting IP address is the trusted IP address. If it is not, it uses an HTTP 301 redirect to gracefully send the user to the site's home page. If the requesting IP address is the trusted address, the proxy_pass option proxies the request to the Ghost webserver.

  2. location = /ghost/api/v3/admin/session - Blocking access to this URI prevents brute forcing of the admin portal using scripting tools or Burp Suite. When a user attempts to authenticate to the admin portal, credentials are POSTed to the /ghost/api/v3/admin/session URI. A savvy attacker will not need to visit the /ghost/ page in a web browser, as they already can authenticate by submitting credentials to the form's specified URI. The image below shows this in Burp Suite. Invalid credentials result in a 401 Unauthorized status code, which means the credentials were tried and incorrect. This occurs even when the /ghost/ URI is blocked.

When the above URI is restricted to trusted IP addresses, the status code returned is 403: Forbidden, which prevents credentials from even being tried against the webserver.

3. location /ghost/api- From watching the docker container logs, Ghost uses the API to serve content and peform backend functions, so this location block passes all traffic to the API through nginx and to the Ghost container, allowing Ghost to handle authentication and permissions. This would be normal behavior, as nginx does not normally handle API traffic any differently. This location block may not be required, but I wanted to ensure API requests were not affected. Notice the lack of '=', which allows the location to match on a wildcard of any URI requested after /ghost/api, whereas using an '=' only matches that exact URI defined.

Reducing Unnecessary System Version Disclosure

System version disclosure can occur in sources such as HTTP headers, server error messages, and page source code. I am interested in limiting the nginx version information and preventing direct IP access to the web server.

  1. Limiting nginx version information from direct IP access - The /etc/nginx/conf.d/default.conf configuration file defines how the nginx server should handle requests with no server name, as is the case with direct IP access to the site, such as http://3.84.178.124/. The default response is a 503 Service Unavailable response, displaying the nginx server information. I changed this to 444 Connection Closed Without Response (see full configuration file below) to even prevent the client from seeing the nginx 503 error page. This is accomplished by replacing all instances of 503 with 444 in the /etc/nginx/conf.d/default.conf file. While this does not provide any real security, no letitimate users would be accessing the site over direct IP access, and internet scanners usually scan based on IP address, not domain name. A 444 response does not reveal any version information as the TCP connection is closed by nginx right after the handshake is negotiated. Chrome returns an ERR_EMPTY_RESPONSE message, as shown below.

Limiting nginx version information in HTTP headers - Showing both the major and minor version of nginx is unnecessary, and it occurs by default in nginx. In this case, the HTTP response headers show Server: nginx/1.17.6:

To hide the major and minor version of nginx, add server_tokens off; to the /etc/nginx/conf.d/default.conf file within the server blocks listening on ports 80 and 443. When this change is implemented, the Server header is just nginx. Nginx could be removed from the server response header entirely, but I have no interest in hiding that nginx is used for this blog, considering the content of this article.

Preventing Clickjacking with the X-Frame-Options Header

Clickjacking is a technique where a user clicks a web page element disguised as another element, which could lead to unintended actions being performed on behalf of the user. An example would be the blakejarvis.com website displayed inside another website, as shown below. On top of the web page could be javascript that could steal passwords or run malicious code, and the user could click on this, thinking they were interacting directly with the blakejarvis.com website.

With no prevention, I perform a clickjacking attack using a local HTML file with an iframe tag to load the blakejarvis.com web page (proof-of-concept HTML source code located here). Notice the C: share in the image URL bar, which is a local HTML file, not blakejarvis.com

Clickjacking can be prevented using the X-Frame-Options header, which is a response header that tells the browser how a site's content can be served. The following code was added to the server blocks hosting the site on ports 80 and 443 to prevent clickjacking (see full configuration file below):

nginx.conf
add_header X-Frame-Options "SAMEORIGIN";

SAMEORIGIN tells the browser that the website content of blakejarvis.com cannot be served in an iframe on a site other than blakejarvis.com. This can be verified by reloading the nginx server and refreshing the proof-of-concept page:

Making the Configuration Changes Permanent

In testing, I manually edited the nginx configuration files within the container and used service nginx reload to update nginx without restarting the container. This is sufficient when running nginx on the host operating system. However, changes made in a nginx docker container would be lost on a container restart or update.

To make things a bit more complicated, the nginx-proxy image auto-generates the /etc/nginx/conf.d/default.conf files based on parameters fed into the nginx.tmpl template file. This makes deployment a breeze, as all configurations are defined in the nginx-proxy Dockerfile which creates the default.conf file. To modify this file pre-image creation, I created a new nginx-proxy image by editing the nginx.tmpl file, and building a new image:

default.conf
git clone https://github.com/nginx-proxy/nginx-proxy.git
cd nginx-proxy
emacs nginx.tmpl

### File shortened for brevity; perform the following changes:
### Replace the 503 status codes with 444 on lines 151 and 162
### Add the following after "{{ $access_log }}" on lines 254 and 277:
###      add_header X-Frame-Options "SAMEORIGIN";
###      server_tokens off;

### Add the following code the nginx.tmpl starting on line 336
    location = /ghost/ {
            if ($remote_addr != [trusted_ip]) {
                    return 301 https://$host;
            }
            proxy_pass http://{{ $host }}$request_uri;
    }
    location = /ghost/api/v3/admin/session {
            allow  [trusted_ip];
            deny all;
            proxy_pass http://{{ $host }}$request_uri;
    }
    location /ghost/api {
            allow all;
            proxy_pass http://{{ $host }}$request_uri;
    }
#save the file and build a new image
docker build -t nginx-proxy-ghost:test .

Finally, the docker-compose.yml file used to deploy the ghost, nginx-proxy, and Lets Encrypt containers was updated, replacing jwilder/nginx-proxy with the local nginx-proxy-ghost:test image, and the containers were restarted with docker-compose up!

Full /etc/nginx/conf.d/default.conf File

This file is rendered from the modified version of the nginx-proxy nginx.tmpl file.

default.conf
# If we receive X-Forwarded-Proto, pass it through; otherwise, pass along the
# scheme used to connect to this server
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
  default $http_x_forwarded_proto;
  ''      $scheme;
}
# If we receive X-Forwarded-Port, pass it through; otherwise, pass along the
# server port the client connected to
map $http_x_forwarded_port $proxy_x_forwarded_port {
  default $http_x_forwarded_port;
  ''      $server_port;
}
# If we receive Upgrade, set Connection to "upgrade"; otherwise, delete any
# Connection header that may have been passed to this server
map $http_upgrade $proxy_connection {
  default upgrade;
  '' close;
}
# Apply fix for very long server names
server_names_hash_bucket_size 128;
# Default dhparam
ssl_dhparam /etc/nginx/dhparam/dhparam.pem;
# Set appropriate X-Forwarded-Ssl header
map $scheme $proxy_x_forwarded_ssl {
  default off;
  https on;
}
gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
log_format vhost '$host $remote_addr - $remote_user [$time_local] '
                 '"$request" $status $body_bytes_sent '
                 '"$http_referer" "$http_user_agent"';
access_log off;
		ssl_protocols TLSv1.2 TLSv1.3;
		ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
		ssl_prefer_server_ciphers off;
resolver [resolver_ip];
# HTTP 1.1 support
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $proxy_connection;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Ssl $proxy_x_forwarded_ssl;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
# Mitigate httpoxy attack (see README for details)
proxy_set_header Proxy "";
server {
	server_name _; # This is just an invalid value which will never trigger on a real hostname.
	listen 80;
	access_log /var/log/nginx/access.log vhost;
	return 444;
}
server {
	server_name _; # This is just an invalid value which will never trigger on a real hostname.
	listen 443 ssl http2;
	access_log /var/log/nginx/access.log vhost;
	return 444;
	ssl_session_cache shared:SSL:50m;
	ssl_session_tickets off;
	ssl_certificate /etc/nginx/certs/default.crt;
	ssl_certificate_key /etc/nginx/certs/default.key;
}
# blakejarvis.com
upstream blakejarvis.com {
				## Can be connected with "blakejarviscom_default" network
			# blakejarviscom_ghost_1
			server [docker_server];
}
server {
	server_name blakejarvis.com;
	listen 80 ;
	access_log /var/log/nginx/access.log vhost;
	add_header X-Frame-Options "SAMEORIGIN";
	server_tokens: off;
    # Do not HTTPS redirect Let'sEncrypt ACME challenge
	location /.well-known/acme-challenge/ {
		auth_basic off;
		allow all;
		root /usr/share/nginx/html;
		try_files $uri =404;
		break;
	}
	location / {
		return 301 https://$host$request_uri;
	}
}
server {
	server_name blakejarvis.com;
	listen 443 ssl http2 ;
	add_header X-Frame-Options "SAMEORIGIN";
	server_tokens: off;
    access_log /var/log/nginx/access.log vhost;
	ssl_session_timeout 5m;
	ssl_session_cache shared:SSL:50m;
	ssl_session_tickets off;
	ssl_certificate /etc/nginx/certs/blakejarvis.com.crt;
	ssl_certificate_key /etc/nginx/certs/blakejarvis.com.key;
	ssl_dhparam /etc/nginx/certs/blakejarvis.com.dhparam.pem;
	ssl_stapling on;
	ssl_stapling_verify on;
	ssl_trusted_certificate /etc/nginx/certs/blakejarvis.com.chain.pem;
	add_header Strict-Transport-Security "max-age=31536000" always;
	include /etc/nginx/vhost.d/default;
	location / {
		proxy_pass http://blakejarvis.com;
	}
	location = /ghost/ { 
		if ($remote_addr != [trusted_ip]) {
			return 301 https://$host;
		}
		proxy_pass http://blakejarvis.com$request_uri;
	}
	location = /ghost/api/v3/admin/session {
		allow  [trusted_ip];
		deny all;
		proxy_pass http://{{ $host }}$request_uri;
	}
	location /ghost/api {
		allow all;
		proxy_pass http://{{ $host }}$request_uri;
	}
}
Additional

Additional Resources

  1. X-Frame-Options (MDN Web Docs)

  2. Nginx Location (Nginx.org)

Last updated