# How to Secure the Ghost Blogging Platform

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

The Ghost blogging platform has built-in security [features](https://ghost.org/docs/concepts/security/) 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>.

![](/files/-MQ8h8Q5OLC0wr23lJVa)

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.

{% code title="user\@blakejarvis" %}

```
docker exec -it c979d3182d71 bash
apt-get update && apt-get install nano
```

{% endcode %}

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:

{% code title="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;
```

{% endcode %}

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):

{% code title="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;
}
```

{% endcode %}

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.

![](/files/-MQ8hwizdYdrEVBELuK1)

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.

![](/files/-MQ8i9nJ101sVgxS_KXV)

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.

![](/files/-MQ8iWZQg1QwqZVMvenv)

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`:

![](/files/-MQ8igvgXHjTF1xMfAsz)

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](https://gist.github.com/bulldawgs34/db7456205765a83a6dcb36f6346ef34e)). Notice the *C:* share in the image URL bar, which is a local HTML file, not blakejarvis.com

![](/files/-MQ8j5wzzs53aR_u9JMY)

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):

{% code title="nginx.conf" %}

```
add_header X-Frame-Options "SAMEORIGIN";
```

{% endcode %}

`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:

![](/files/-MQ8jDvWY4FYZ3CSJtey)

### 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](https://github.com/nginx-proxy/nginx-proxy) image auto-generates the `/etc/nginx/conf.d/default.conf` files based on parameters fed into the [nginx.tmpl](https://blakejarvis.com/p/3a9cc8e0-057f-46ee-af38-886384f73e5f/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:

{% code title="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 .
```

{% endcode %}

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.

{% code title="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
```

{% endcode %}

### Additional Resources

1. [X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) (MDN Web Docs)
2. [Nginx Location](https://nginx.org/en/docs/http/ngx_http_core_module.html#location) (Nginx.org)


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://www.blakejarvis.com/miscellaneous-articles/how-to-secure-the-ghost-blogging-platform.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
