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:
Restricting access to the Ghost admin portal to trusted IP address ranges
Reducing unnecessary system version disclosure in HTTP response headers
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.
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:
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):
Taking each of these blocks one by one:
location = /ghost/
- Anif
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, theproxy_pass
option proxies the request to the Ghost webserver.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 a401 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.
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 a503 Service Unavailable response
, displaying the nginx server information. I changed this to444 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 of503
with444
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 anERR_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):
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:
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.
Additional Resources
X-Frame-Options (MDN Web Docs)
Nginx Location (Nginx.org)
Last updated