Security Without Authentication - Shareable Secret Links in Django
This blog post was motivated by a clever redditor who went and wrote a script to keep track of his friends' orders on dominos.com (and presumably mysteriously show up on their doorsteps just ahead of the pizza delivery guy). It turns out that if you go to dominos.com and order a pizza you'll be sent to an order status page that uses your phone number to find your order. So if you know someone's phone number, it's pretty easy to check if they're ordering from Domino's. This is a security issue: information that should be private is publicly discoverable. Having your pizza ordering public isn't the end of the world, but for more sensitive information this could be a real problem. Also, I'm not sure I want uninvited hungry friends showing up at my doorstep. As it turns out this is a fairly common problem, and Django gives us the tools to solve it very easily.
We can phrase the problem more generally: how do we provide access to some
functionality to unauthenticated users without providing the same access to the
whole world? This is the exact same problem we face with password reset emails.
The solution is to use a one way hash to generate a url which can't be guessed
in a reasonable time, but that we can verify easily. The
django.core.signing.Signer
class does exactly this. Assume we have an Order
model and we want to provide access to an order-status
view only to users who
have a url. Here's some minimal code:
# orders/models.py
from django.core.signing import Signer
from django.db import models
from django.urls import reverse
class Order(models.Model):
...
signer = Signer(sep='/', salt='orders.Order')
def get_absolute_url(self):
signed_pk = self.signer.sign(self.pk)
return reverse('order-status', kwargs={'signed_pk': signed_pk})
# orders/views.py
from django.core.signing import BadSignature
from django.http import Http404
from .models import Order
def order_status(request, signed_pk):
try:
pk = Order.signer.unsign(signed_pk)
order = Order.objects.get(pk=pk)
except (BadSignature, Order.DoesNotExist):
raise Http404('No Order matches the given query.')
...
# orders/urls.py
from .views import order_status_view
url_patterns = [
url(
r'order/(?P<signed_pk>[0-9]+/[A-Za-z0-9_=-]+)/$',
order_status_view,
name='order-status'
),
]
</pre>
Most of the magic here is being done by the Signer
. Under the hood this
generates a hash using the our site's SECRET_KEY
, our order's pk
and the
salt. By using the SECRET_KEY
as part of the hash input we guarantee that
someone with only the pk
can't generate the hash themselves. If we used a
Signer
with the same salt somewhere else on the site, an attacker might be
able to trick the site into telling them what the hash for some value should
be, so we use the module path to our class to salt the hash. Now the site will
generate totally different hashes for a given input on different parts of the
site. Under the hood, the signer just combines those three values using
Python's hashlib.SHA1
hasher. The unsign
method verifies that the
signed_pk
is correct (by recalculating the hash), and takes care of such
niceties as using an equal time comparison so that some sort of timing based
attack can't be used. The result is a secure, unreversable, and effectively
unguessable hash. The url can be accessed by anyone, but for all practical
purposes only someone given the url can find it.
And that's really all there is to it! Any request that doesn't have a valid
signed_pk
will return a 404. There are some disadvantages to making the
authentication part of the url of course. First, the url is only secure if it's
kept secret - if a user posts a url publicly, then there's no way to revoke
access with this scheme. Of course this is a problem for passwords too, but
users aren't trained to keep urls secret. Django does include a
TimestampSigner
class which includes a timestamp as part of the hash. This
can be used to have links which expire. A more subtle concern is that it's
normal for servers to keep urls in log files. Your log files should be well-secured so that no one who shouldn't have access to them does, but it's
still not a great practice to have potentially private information in multiple
places. As a result, it's best to only use these sorts of urls for short-term
or non-critical access. Finally, you have to be careful with external links
from a protected view. Users following those links will have a REFERER
header
which could reveal the link to the operators of the linked site. For important
information, it would be worth being careful not to link to external pages from
secure view. For pizza order status we can probably choose not to worry about
that.
So, something like this is what Domino's probably should be doing. One of the real joys of working with Django is that the tools you need to make well-designed, secure websites are usually already there. The batteries-included angle is nice for quick development, but downright essential for security where subtle and easy-to-make mistakes can really only be avoided by running well-tested and vetted code. So I suspect dominos.com isn't written in Django, but maybe if it were, my pizza orders would be private!
Fusionbox provides our clients with Django Security Audits. Get in touch for a code review.