Everyone knows passwords are terrible, so I’ve been using django-nopassword to enable password-less logins on a handful of Django sites I’ve built. The technique is simple enough: you provide only your email address and a link is sent to that email address. You click the link, which contains a special login code, and the website logs you in and deactivates the code so it can no longer be used. Subsequent visits to that URL just give a 404. In other words, every login is treated like a password reset. For many use cases, this could be a real pain, but for the case of, say, a site where a handful of internal users need access to an admin panel, it’s really nice to not have to deal with one more password.
A few weeks back, however, I discovered my login attempts weren’t working when I used Mailbox on my iPad. I’d click the link, Safari would open, and I’d get a 404. Click the link from Mail.app instead? No problem. What’s going on with Mailbox? So I looked at my server logs:
188.8.131.52 [28/Sep/2014:19:51:48 -0500] "GET /login-code/q3Zm23lKe0irIW7ueRKU/ HTTP/1.1" 401 188 "-" "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" xx.xxx.xxx.xxx [28/Sep/2014:19:51:49 -0500] "GET /login-code/q3Zm23lKe0irIW7ueRKU/ HTTP/1.1" 302 5 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0_2 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12A405 Safari/600.1.4"
That second request to my login code URL is me in Safari. The first one? Facebook. Facebook?! What the hell? Why is my login code being shared with Facebook?
Imagine someone sends you a Pinterest pin in an email. If you have the Pinterest app installed on your phone, just tap the link in Mailbox and it will open in your Pinterest app rather than Safari or Chrome. You get to access information on your mobile device the way you want — and not through the default web view.
How do App Links know how to do this super considerate service? By sending every URL you request to Facebook, which then requests the URL to check for support for App Links. So this is why Facebook was following my one-time login code URL, invalidating the code before Safari could get to it — and, I suppose theoretically, logging into my website in my place. Yikes.
Using email-based logins obviously makes this more salient for me in this particular case, but any application that uses this method for password resets, and many do, would have the same problem with links clicked through Mailbox.
I should point out I do not have Facebook installed on my iPad nor am I logged into my Facebook account via iOS, and there is no setting anywhere in Mailbox to enable or disable this “feature”. I did not ask to have every URL I clicked in my personal email sent to Facebook and had I not been watching my own server logs, I would never have known this was happening. This is seriously creepy.
Shaming Facebook…well, what’s the point. But Mailbox? You should know better. It’s already stretching my trust to give you all of my personal email. And of course Facebook wants to inject themselves in between every http request on the internet. But we don’t have to help them out. At minimum, this should be something I can disable. Really, it should be opt-in as well.
Here, I’ll even write the alert for you:
Silently enabling this in the app so that I’d only know about it if I read your blog? Unacceptable.
There was a two-part fix.
Part one was to add user agent checking as part of the login code validation. Using the user-agents package, I replaced django-nopassword’s
login_with_code view with the following:
import user_agents from django.http import HttpResponseForbidden from nopassword.views import login_with_code_and_username from nopassword.models import LoginCode def login_with_code(request, login_code): ua = user_agents.parse(request.META.get('HTTP_USER_AGENT') or '') if not (ua.is_pc or ua.is_mobile or ua.is_tablet): return HttpResponseForbidden() code = get_object_or_404(LoginCode.objects.select_related('user'), code=login_code) return login_with_code_and_username(request, username=get_username(code.user), login_code=login_code)
Ugh. User Agents are awful.
Part two of the fix: stop using Mailbox.
Update: There’s actually a better “part one”. I created an issue on github for django-nopassword and the latest version now uses a POST instead of a GET request to login, which both solves the problem here and is also consistent with the principle that GET requests should be idempotent.
Update 2: Mailbox is disabling App Links in version 2.3.3.