Post

GHSA-435m-gr6q-2fg6

GHSA-435m-gr6q-2fg6

GHSA-435m-gr6q-2fg6 — Joplin Server Unauthenticated Account Takeover

  • Infinit3i

This is a vulnerability I found and reported in Joplin Server, the self-hosted sync backend behind the Joplin note-taking app. It’s an unauthenticated account takeover: if I can get you to click one “approve” button while you’re logged in, I walk away with permanent credentials to your account — from anywhere on the internet, with no login of my own. Your notes, your notebooks, your attachments, all of it.

I reported it privately and it’s now published as GHSA-435m-gr6q-2fg6. It affects Joplin Server up to and including 3.7.1, with a CVSS v4.0 score of 8.5. GitHub filed it under three weaknesses: missing authentication for a critical function (CWE-306), insufficiently random values (CWE-330), and incorrect authorization (CWE-863). No CVE was assigned.

What the feature was supposed to do

Joplin Server has a device-pairing flow. The idea is normal enough: a new device shows an identifier, you approve that identifier in your browser while logged in, and the device then trades it for long-lived credentials so it doesn’t have to keep asking for your password. Convenient, and on paper it sounds fine.

The problem is that I get to pick the identifier, and the server never checks who’s actually redeeming it.

Why it breaks

Once I started pulling on that thread, basically every safety check you’d expect to be there just… wasn’t. Here’s what I found:

  • The identifier is mine to choose. The server takes an applicationAuthId straight from the caller (ApplicationModel.ts:73) instead of generating something random on its own. So I can decide up front that the identifier is attacker-chosen-0001 and know exactly what to redeem later.
  • The consent page tells you nothing. The approval screen (routes/index/applications.ts:21) is a fixed blob of text — no device platform, no version, no source IP, no short code to match against. You have no way to tell what you’re actually approving, so a generic “approve this device?” is all you see.
  • Redemption needs no auth at all. The redeem endpoint (routes/api/application_auth.ts:9) is marked router.public = true and hands path.id straight to createAppPassword without checking who’s asking. Anyone who knows the identifier — me — can cash it in.
  • Nothing expires and nothing rate-limits. Those pre-login records have no TTL, no server-generated nonce, no throttling (ApplicationModel.ts:139). My chosen identifier just sits there, redeemable, for as long as I want.
  • CSRF protection doesn’t fire. Router.isPublic() (utils/Router.ts:50) decides “public or not” per URL path rather than per HTTP method, so the CSRF check on the approve action is effectively switched off (routes/index/applications.ts:19).
  • And it skips MFA on purpose. The credentials minted through this path bypass MFA when you exchange them for a session (routes/api/sessions.ts:30), so even a victim with two-factor turned on isn’t protected here.

Any one of these on its own would be a bug worth fixing. Stacked together, they turn a device-pairing convenience into a one-click account takeover.

Walking through the attack

Here’s how it actually plays out:

  1. I pick an identifier — say attacker-chosen-0001.
  2. I send you a link to the approval page carrying that identifier.
  3. You’re already logged in, so you land on the generic consent screen. Nothing on it looks alarming.
  4. You click approve. There’s no CSRF check to stop this.
  5. From my own machine, on any network, I hit the public redeem endpoint with the same identifier.
  6. The server hands me an application ID and password, no questions asked:

    1
    
    {"id":"2b3dc9a6-ec73-45f4-aacc-f05f0e6c6d93","password":"kFlv_yjfdwZoEOEoBKFiQK..."}
    
  7. I trade those for a session token at POST /api/sessions, and it resolves to your user ID.
  8. From there I have full read/write access to everything in your account.

Proof of concept

I wrote a small PoC (poc_1_appauth_unauth_account_takeover.py, standard library only) that runs the whole chain start to finish. It first confirms the server has never heard of my chosen identifier, logs in as the victim and approves the generic consent page, then redeems credentials with no authentication — returning the id and password shown above. It exchanges those for a session token that resolves to the victim’s account ("user_id":"Yk7AWIPSsPqLsXiSUMKvda") and finishes by pulling the victim’s private notes to prove it’s real access, not a partial foothold.

Why it matters

The short version: an attacker who never logs in ends up holding permanent credentials to someone else’s account, with full read and write over their notes, notebooks, and attachments. And it’s quiet. The only trace on the victim’s side is a single extra row on their /applications page — populated with platform, version, and IP values that I supplied, which means I can dress it up to look like a legitimate device and make the unauthorized access blend right in.

For me this one was a great reminder of a pattern that shows up again and again: the moment a server treats client-controlled input as if it were its own secret, and then piles a vague consent screen, a method-blind CSRF check, and an MFA carve-out on top, the whole trust model quietly falls apart.

This post is licensed under CC BY 4.0 by the author.