Home Blog Security Without Authentication - Shareable Secret Links in Django

Security Without Authentication - Shareable Secret Links in Django

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' ), ]

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.