Apache redirects & Django security

Version info

When writing this guide, I used the following:

  • A Linode Nanode server with Ubuntu 20.04 LTS
  • Apache 2.4.41
  • mod_wsgi 4.7.1
  • Python version 3.8 (using the venv package for virtual environment management)
  • Django version 3.1
  • certbot 1.7.0, installed by using sudo snap install --classic certbot (as suggested on the certbot website)

Prerequisites

This guide is specifically for people who have gone through Corey M. Schafer's (coreyms) Django tutorial, up to and including "Python Django Tutorial: How to enable HTTPS with a free SSL/TLS Certificate using Let's Encrypt".

I have no affiliation with coreyms and don't know him at all, but I think his tutorial is great and would like to help others who might have had trouble setting up redirects after doing his tutorial.

Aim

In the HTTPS/certification-related video I linked to above, coreyms mentions that in a future video he would like to set things up so that when users go to e. g. 'www.myawesomeapp.com', they'd instead be sent to 'myawesomeapp.com'. I couldn't find any material from him so far on doing this, and I saw in the YouTube comments section for the video that others were also unsure about how to go about it. Also, I noticed that if I tried going to 'http://datalowe.com' (since 'datalowe.com' is the domain name I used), that didn't work at all. I was just shown a warning about how the server was trying to use a certificate that was only valid for 'www.datalowe.com'. So in this guide we'll:

  • Acquire an updated/expanded certificate from Let's Encrypt, that also works for when leaving out the 'www.' part.
  • Set up redirects so that regardless of whether the user types in 'example.com', 'www.example.com', 'http://example.com', 'http://www.example.com', 'https://example.com' or 'https://www.example.com', they are always sent to 'https://example.com'.
  • Configure Django to explicitly tell it to only do things via HTTPS/SSL.

A word of caution

I haven't received any feedback on this approach (please write to me if you have any). I give no guarantees on, and take no responsibility for, whether it will work on your setup, or if it's secure for you to use. It's likely that some of the steps I describe could be skipped or done in a better way. I'm only sharing what happened to work for me. Taking all this together, you should make copies (cp) of your files before you start editing them, so you can always return to where you started if things are broken.

Starting point

If you've gone through coreyms' tutorial, you should have the following:

  • A hosting server (Linode, if using the same as coreyms), that uses Apache2 and has a working Django project
  • SSH access to the server, with SSH key login set up for a user with sudo access
  • A domain name that you've linked up to your hosting server
  • A certificate from Let's Encrypt, acquired by using their 'certbot'
  • A configuration file that describes how Apache2 should deal with incoming HTTP requests, say '/etc/apache2/sites-available/django_project.conf', that redirects users to 'https://' addresses
  • A configuration file for incoming HTTPS requests, say '/etc/apache2/sites-available/django_project-le-ssl.conf'

If any of the above seems unfamiliar to you, you will probably have to go back and rewatch coreyms' tutorial.

SSH in

We won't be doing anything locally so ssh into your server - you should be familiar with this also from doing the tutorial. Assume that all following commands in this guide are done on the server.

User:~ user$ ssh myusername@100.100.100.100

Stopping the server

This isn't strictly necessary (you could instead use sudo service apache2 restart once we're done) for any of the steps we follow here, since nothing should come into effect until the server is restarted anyway, as far as I know. But I prefer to shut things down entirely while I'm mucking about with settings until I think everything is good to go again.

username@my-server:~$ sudo service apache2 stop

Acquiring an additional certificate

First let's deal with the issue where anyone going to example.com instead of www.example.com gets a message about invalid use of a Let's Encrypt certificate. There is actually very helpful certbot documentation about changing a certificate's domains. Do have a look at that link, because their explanation is super clear. In our case, what we have to do is:

certbot certonly --cert-name www.example.com -d www.example.com, example.com

You should get a message that informs you the certificate has been updated/expanded.

Configuring HTTP request redirects

We use nano to open up our configuration file that describes what should happen for HTTP requests (those coming in through port 80).

user@my-server:~$ sudo nano /etc/apache2/sites-available/django_project.conf

This is what things should look like right now (omitting the irrelevant parts)

<VirtualHost *:80>
        # ...
        ServerName www.example.com

        # ...
        #Include conf-available/serve-cgi-bin.conf

RewriteEngine on
RewriteCond %{SERVER_NAME} =www.example.com
RewriteRule ^ %{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
# ...

Do you see those same lines in your .conf file? If not, again, rewatch coreyms' tutorial.

To understand the parts related to "Rewrite...", I found this tutorial by Matt Doyle about mod_rewrite useful. I admit that I didn't read the whole thing yet, but here's a breakdown of what we have, as I understand the parts:

  • RewriteEngine on - "Turns on the mod_rewrite rewriting engine.", as Doyle writes
  • RewriteCond - Describes a condition for when the rewrite (redirect) should happen, similar to an if statement in Python
  • %{SERVER_NAME} - Fetches the value of a server variable, which holds the server's hostname. (the %{VAR} syntax is used in general for referring to server variables)

So RewriteCond %{SERVER_NAME} =www.example.com means, "if the server variable SERVER_NAME is equal to 'www.example.com', then...". Moving on to the "then" section:

  • RewriteRule - Describes a rule for rewriting one URL to another, here using server variables.
  • ^ A regular expression which describes what pattern the request URL should match for the rule to be applied (so sort of like an additional 'if' condition). ^ marks the start of what's being matched for, just like with regex in Python (commonly used with the re package). When nothing follows the ^, that means that whatever the URL is, the redirect rule should be applied.
  • %{REQUEST_URI} - A server variable which holds "the path component of the requested URI, such as '/index.html'." (from Apache's official docs)
  • [END,NE,R=permanent] - Flags that modify how the rule is applied.
    • END: Marks this as the final (and only, in our case) rewrite rule that is to be appliead
    • NE: "noescape", prevents apache from converting special characters (which is otherwise the default), like &, into their hexcode equivalents. So if the user tries to access 'www.example.com/start?title=bar', they'll be sent to 'https://www.example.com/start?title=bar', instead of (with conversion) 'https://www.example.com/start%3ftitle=bar'. That's what we want - the path should be left "as is".
    • R=permanent, means that when the user is redirected, the 301 (moved permanently) status code is sent. This tells the browser to save this redirect and use it right away. So if the user has been redirected once from 'www.example.com' to 'https://www.example.com', the next time the user tries to go to 'www.example.com' the browser won't even bother going there to get a redirect response, and instead it goes to 'https://www.example.com' straight away.

Now that we understand how the redirects are happening, what's missing? Well, there's nothing in there to say what should happen if the user tries to go to 'example.com' instead of 'www.example.com'. Let's change (using nano) that, and while we're at it, make sure that all redirects (that are sent in response to HTTP requests) go to 'https://example.com'.

<VirtualHost *:80>
        # ...
        ServerName www.example.com
        ServerAlias example.com # new

        # ...
        #Include conf-available/serve-cgi-bin.conf

RewriteEngine on
RewriteCond %{SERVER_NAME} =www.example.com [OR] # modified
RewriteCond %{SERVER_NAME} =example.com # new
RewriteRule ^ https://example.com%{REQUEST_URI} [END,NE,R=permanent] # modified
</VirtualHost>
# ...

We added an alternative name for our host, using ServerAlias. We also added an alternative condition for our SERVER_NAME server variable. Similar to how in python we can write if cond1 or cond2:, we use [OR] and an additional condition to tell Apache that if either condition is fulfilled, the rewrite rule should be applied. It might seem odd to you that the server name can vary, and I don't entirely understand it myself. But if the user goes to example.com, then Apache recognizes that as matching the alternate host name we specified, and assigns this to the SERVER_NAME variable. That's what I think happens - if someone knows better, please do write me so I can correct this.

(sidenote: if you want to read more about [OR], you can check out this Stack Overflow thread that helped me understand it)

We changed the RewriteRule to redirect to 'https://example.com%{REQUEST_URI}'. This means that the REQUEST_URI server variable will still be used for determining the path component to tack on at the end, but no matter what the value of SERVER_NAME (e. g. if it's 'www.example.com') is, the user is always sent to the 'example.com' domain. Note that if you ever change your domain names, you must also change this redirect rule, because otherwise 'yourotherwebsite.com' will still redirect your users to 'https://example.com\

When you're done, exit using ctrl+x and confirm that you want to save the changes.

Configuring HTTPS request redirects

Now we've ensured that users who go to e. g. 'www.example.com' or 'http://example.com' will be sent to 'https://example.com'. But if someone (for whatever reason) goes to the trouble of writing out the full 'https://www.example.com' address, they won't be redirected. To change this, we use a similar approach as above. Go the .conf file that describes what happens upon HTTPS requests (the one with '-le-ssl' in its name)

user@my-server:~$ sudo nano /etc/apache2/sites-available/django_project-le-ssl.conf

You should see something like this:

<IfModule mod_ssl.c>
<VirtualHost *:443>
        # ...
        ServerName www.example.com

        # ...
        Alias /static /home/user/django_project/static
        <Directory /home/user/django_project/static>
                Require all granted
        </Directory>
        # ...

        WSGIScriptAlias / /home/user/django_project/django_project/wsgi.py
        WSGIDaemonProcess django_app python-path=/home/user/django_project python-home=/home/user/django_project/venv>
        WSGIProcessGroup django_app

SSLCertificateFile /etc/letsencrypt/live/www.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/www.example.com/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf

</VirtualHost>
</IfModule>

There are no redirect rules here at all, so let's add one and change the ServerName, dropping the 'www' part. Hopefully you can figure out why/how this works, based on the steps we worked our way through above.

<IfModule mod_ssl.c>
<VirtualHost *:443>
        # ...
        ServerName example.com # modified

        # ...
        Alias /static /home/user/django_project/static
        <Directory /home/user/django_project/static>
                Require all granted
        </Directory>
        # ...

        WSGIScriptAlias / /home/user/django_project/django_project/wsgi.py
        WSGIDaemonProcess django_app python-path=/home/user/django_project python-home=/home/user/django_project/venv>
        WSGIProcessGroup django_app

SSLCertificateFile /etc/letsencrypt/live/www.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/www.example.com/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf

RewriteEngine on # new
RewriteCond %{SERVER_NAME} =www.example.com # new
RewriteRule ^ https://example.com%{REQUEST_URI} [END,NE,R=permanent] # new

</VirtualHost>
</IfModule>

That's it for the Apache configuration files.

Reconfiguring django

At a certain point in coreyms' tutorial for using a custom domain name, he modifies the ALLOWED_HOSTS setting so that the list also includes his www.myawesomeapp.com. Unless I missed something, he forgets however to add myawesomeapp.com. Let's fix this, and also specify some additional security settings.

Deployment check

Before we actually modify the 'settings.py' file, let's check what Django thinks we should change to make our Django project more secure.

# activate the virtual environment
user@my-server:~$ source django_project/venv/bin/activate 
# run the check command with the deploy flag
(venv) user@my-server:~$ python django_project/manage.py check --deploy

If you haven't already modified your 'settings.py' file after finishing coreyms' tutorial, you'll probably get at least these messages:

System check identified some issues:

WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. If your entire site is served only over SSL, you may want to consider setting a value and enabling HTTP Strict Transport Security. Be sure to read the documentation first; enabling HSTS carelessly can cause serious, irreversible problems.
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only session cookie makes it more difficult for network traffic sniffers to hijack user sessions.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. Using a secure-only CSRF cookie makes it more difficult for network traffic sniffers to steal the CSRF token.

Thankfully, the Django docs explain very clearly what the messages mean. Here are links to relevant Django docs for each issue:

Please read through the above Django docs carefully, so that you understand what each setting relates to and you can make your own informed decision about whether or not you're ready to throw the switches.

All of these messages relate to HTTPS/SSL, so we should be able to implement the suggested changes.

Editing settings.py

(venv) user@my-server:~$ sudo nano django_project/django_project/settings.py 

In the file, change

ALLOWED_HOSTS = [
    'www.example.com',
    '111.222.333.444', # your server's IP address
]

to

ALLOWED_HOSTS = [
    'www.example.com',
    'example.com',
    '111.222.333.444', # your server's IP address
]

If you want, and you're certain that you did the previous steps correctly, you can actually try leaving out the 'www.example.com', part, since our server should be redirecting all users to 'example.com' anyway.

Quick check

You can, if you'd like, now save your changes and exit, then run sudo service apache2 start and connect to your website using a web browser. See if you are properly redirected, no matter how you try to access the site.

Now let's add the specifications, and some additional ones related to HSTS, that Django suggested when we ran our check command. You can put put these at the very end of your 'settings.py' file (or wherever you prefer). It's probably wise to add these one by one, restarting your apache2 server inbetween and checking that everything still works okay.

SECURE_HSTS_SECONDS = 60
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True 
CSRF_COOKIE_SECURE = True
SECURE_HSTS_PRELOAD = True

Once you're sufficiently confident that the redirects and the site in general are properly set up, you might want to increase the SECURE_HSTS_SECONDS setting, as the Docs suggest. Personally I set mine to 86400 seconds, i. e. 24 hours, because I don't know what I'll be changing about the site in the future and I don't want to commit to only running on HTTPS for say a year just yet.

Now try running the check command again.

(venv) user@my-server:~$ python django_project/manage.py check --deploy

You should find that no issues are raised. If that's not the case, you can consult the Django docs again.

Good job!

Give yourself a pat on the back for not only finishing coreyms' tutorial, but doing these extra steps as well!

I am very thankful to coreyms. I'm certain it would have taken me ages to learn how to deploy a Django project using a proper VPS and domain name if I couldn't rely on his series. This guide wouldn't have been possible either, so if you want to support his work, please go to coreyms' website for information on how to do that.

If you want to send any feedback or comments, please contact me at datalouvre@gmail.com.