Develop with https
I've been circling around a combination of problems for a while now. They involve security, HTTPS, and middleware.
If you're familiar with Django, you might recognize these settings:
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
SECURE_HSTS_SECONDS = 0
SECURE_REDIRECT_EXEMPT = []
SECURE_REFERRER_POLICY = "same-origin"
SECURE_SSL_HOST = None
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = None
In development these things don't matter much. But as soon as you go to deploy your site, you're confronted with all kinds of questions about how to keep it secure, what settings to use, and how to manage the differences between development and production (environment variables? multiple Python settings files?).
But what if we developed in https too? How would that change things?
Well, we're going to find out because now the plain dev
command now runs with https!
How it works
Once I worked my way through it, the end result actually feels pretty... plain.
The first ingredient is mkcert. The plain dev
command will automatically download a binary version of mkcert (unless you have it installed already) and prompt you to allow the initial setup. After that we just generate a cert for our local development hostname.
For the hostname, we grab the name of your directory and make it a subdomain of localhost! This works by adding a line to your /etc/hosts
(which I haven't done in years!) to make it resolve. Lastly, we use a default port of 8443 so gunicorn can serve it over https.
Now we have a secure local development site at https://<project>.localhost:8443
!
A new approach to settings
I think that using https in development will have a number of positive trickle down effects. But right off the bat it means we can now redefine Plain's settings to expect https by default! Using https is no longer opt-in — now it's opt-out.
And if we're going to rework some settings, why not take it all the way? The SECURE_
settings have always felt a little funky to me...
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
SECURE_HSTS_SECONDS = 0
SECURE_REDIRECT_EXEMPT = []
SECURE_REFERRER_POLICY = "same-origin"
SECURE_SSL_HOST = None
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = None
It can be nice how explicit they are, but most of these simply translate to response headers. Their explicitness also sets an expectation that for Django to support newer features like CSP, it needs to introduce a suite of new settings to make it work. What if we just went straight to the headers themselves?
Plain is now designed around a dict of DEFAULT_RESPONSE_HEADERS
(plus a few additional settings for redirecting http to https and detecting https behind a proxy).
# plain/runtime/global_settings.py
DEFAULT_RESPONSE_HEADERS = {
"Cross-Origin-Opener-Policy": "same-origin",
"Referrer-Policy": "same-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
# Whether to redirect all non-HTTPS requests to HTTPS.
HTTPS_REDIRECT_ENABLED = True
HTTPS_REDIRECT_EXEMPT = []
HTTPS_REDIRECT_HOST = None
# If your Plain app is behind a proxy that sets a header to specify secure
# connections, AND that proxy ensures that user-submitted headers with the
# same name are ignored (so that people can't spoof it), set this value to
# a tuple of (header_name, header_value). For any requests that come in with
# that header/value, request.is_https() will return True.
# WARNING! Only set this if you fully understand what you're doing. Otherwise,
# you may be opening yourself up to a security risk.
HTTPS_PROXY_HEADER = None
This design is explicit too, but in a totally different way. Now the expectation is that, if you need to customize these kinds of things, you should go straight to understanding the raw headers themselves!
Plain can't really define defaults for things like CSP or HSTS anyways, so when you cross over into that kind of complexity, now all of the headers are in your hands:
# settings.py
DEFAULT_RESPONSE_HEADERS = {
"Content-Security-Policy": "default-src 'self'",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
"Cross-Origin-Opener-Policy": "same-origin",
"Referrer-Policy": "same-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
We'll see if this approach has any shortcomings, but so far I haven't hit any. I expect that we'll need to allow DEFAULT_RESPONSE_HEADERS
to be a callable so that we have an easy way to set a dynamic CSP nonce, for example, but I'm not sure it needs much beyond that.
Built-in middlewares
A side-effect of reworking the "secure" settings is that I also reworked some of the standard middleware. Previously every Plain project had a default MIDDLEWARE
like this:
MIDDLEWARE = [
"plain.middleware.security.SecurityMiddleware",
"plain.middleware.common.CommonMiddleware",
"plain.csrf.middleware.CsrfViewMiddleware",
"plain.middleware.clickjacking.XFrameOptionsMiddleware",
]
But in practice, I don't think there's much reason to ever remove these. They just end up being this thing you have to copy around and try not to mess up.
They've now been rewritten into a set of built-in middleware that are used for every request.
So when you start modifying your settings, MIDDLEWARE
is simply a list of additional middleware that you want to use.
# settings.py
MIDDLEWARE = [
"plainx.sentry.SentryMiddleware",
"plain.sessions.middleware.SessionMiddleware",
"plain.auth.middleware.AuthenticationMiddleware",
"plain.staff.StaffMiddleware",
]
Plain Scan
Ok, if you're on the more experienced end of Django users, you may be wondering what this means for the manage.py check --deploy
system checks that can help you get all of the security settings right!
In reworking this stuff, I've deleted most of the checks. The trick with Django system checks is that they only know about Django. But Django is typically only one of several layers between your code and the user on the other side. What I expect to return is a combination of pre-deploy system checks and a new post-deploy remote scan!
In the end, I think the only way to truly know you have everything configured correctly is to look at it from the outside. I don't want to share any progress yet, but I'm working on a tool/service called Plain Scan that will do just that.