Account Takeover via User-Agent Spoofing in Fastmail's Password Reset
Fastmail's password-reset flow skipped OTP verification whenever a request's User-Agent matched the one recorded at signup, a header the client fully controls, making it trivial to impersonate a 'trusted device' and take over any account.

Fastmail runs a bug bounty program, and I decided to spend a weekend hunting on it. Fastmail is a privacy-focused email provider, the kind of service people switch to specifically to get away from Gmail. Account recovery is usually a good place to start on any target, so that’s where I went first.
I signed up, then clicked “Forgot password” just to see how the flow worked. It asked for my email, and that was it. No code, no confirmation link. It let me straight through to set a new password, like it already knew it was me.
That felt off, so I tried it again from a different browser. This time it wanted to verify my identity properly, and sent a code to my recovery phone:

Same account, same password, same “forgot password” button. One browser walked straight through, the other got stopped for verification. Something on the server was telling these two apart, and I wanted to know what.
Finding the “Same Device” Check
My first guess was IP address. Maybe it trusted requests from an IP it had seen before. I connected to a VPN and tried again from my normal browser. Still got waved through. Not IP.
Next guess was cookies, but I’d cleared cookies before the second browser test and it still asked for the OTP, so that wasn’t it either. That left one thing I hadn’t ruled out yet: the User-Agent header. It’s different between my two browsers, and it lines up exactly with what I was seeing.
If that’s really what’s being checked, it means the whole “is this really you” check comes down to comparing a string the client sends about itself, against whatever string got recorded the last time you logged in. Match it, and the server assumes you’re on a trusted device. There’s no secret in a User-Agent. It’s just a sentence like “I am Chrome on Windows,” and any browser or tool can say whatever it wants. If this string was really acting as a second factor, then knowing someone’s User-Agent is basically the same as knowing their password.
Time to prove it instead of just guessing.
Getting a Victim’s User-Agent
I needed a real target account that wasn’t mine, and a way to see what its browser was sending. I made a second Fastmail account to act as the “victim,” set up an ngrok tunnel, and sent that link over.
Once it was opened, ngrok’s request inspector showed me the whole incoming request, User-Agent included:

In a real attack you wouldn’t even need someone to click a link. Anything that leaks a target’s User-Agent works just as well, and you could probably skip capturing it altogether in most cases. Browser User-Agent strings aren’t as diverse as people think. Most visitors on the internet are running one of a handful of common combinations.
Either way, I now had what I needed, the victim account’s email and the exact string its browser sent at login.
Testing It
I opened Burp Suite, started a password reset for the victim’s account, and caught the outgoing request before it left. Then I did the one thing that shouldn’t matter if this app was actually verifying identity. I overwrote my own User-Agent header with the one I’d just captured.

I forwarded it, half expecting it to get rejected. Instead it came back like I’d just proven exactly who I was:

No OTP, no email confirmation. Just a plain “set your new password” form, sitting right there on an account that wasn’t mine:

I never touched the victim’s inbox, phone, or session. A string copied off one HTTP request was enough to get in.
Reporting It
I wrote it up and sent it through Fastmail’s bug bounty program, expecting a fix. Instead:

I replied explaining why a client-supplied header can’t work as an identity check, and got a second reply saying pretty much the same thing:

Then a third message closed the ticket for good:

I left it for a while and came back to test it again later, mostly out of curiosity. It didn’t work anymore. The flow had changed. Whatever the official answer was at the time, something on their end clearly didn’t stay the same. Make of that what you want.
What stuck with me here wasn’t the exploit itself. Swapping a header in Burp is about as simple as attacks get. It was how much trust got built on top of a value that was never meant to hold any. If a server can’t tell “the real device” apart from “a string that claims to be the real device,” it was never really checking identity in the first place.