Cross-site Request Forgery (CSRF)
Description
The package joplin before 2.3.2 are vulnerable to Cross-site Request Forgery (CSRF) due to missing CSRF checks in various forms.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Joplin prior to 2.3.2 lacks CSRF protection in server forms, enabling unauthorized actions on behalf of authenticated users.
Vulnerability
Joplin versions before 2.3.2 are vulnerable to Cross-Site Request Forgery (CSRF) due to missing CSRF checks in various server-side forms [1][4]. The commit addresses this by adding a createCsrfTag utility and injecting CSRF tokens into forms such as the upgrade page [1].
Exploitation
An attacker can craft a malicious page that submits a request to a Joplin server endpoint where the victim is authenticated. Since no CSRF token is required, the server processes the request as if initiated by the victim, without user interaction beyond visiting the attacker's page [4].
Impact
Successful exploitation allows an attacker to perform state-changing operations on behalf of a victim user, such as modifying settings, upgrading, or other actions exposed via forms, leading to unauthorized data modification or account compromise [4].
Mitigation
Upgrade to Joplin version 2.3.2 or later, which includes CSRF tokens on all relevant forms [1][4]. No workaround is documented; applying the patch is recommended.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
joplinnpm | < 2.3.2 | 2.3.2 |
Affected products
2Patches
119b45de2981cServer: Added form tokens to prevent CSRF attacks
8 files changed · +82 −16
packages/server/src/app.ts+13 −1 modified@@ -135,7 +135,19 @@ async function main() { await next(); } catch (error) { ctx.status = error.httpCode || 500; - ctx.body = JSON.stringify({ error: error.message }); + + // Since this is a low level error, rendering a view might fail too, + // so catch this and default to rendering JSON. + try { + ctx.body = await ctx.joplin.services.mustache.renderView({ + name: 'error', + title: 'Error', + path: 'index/error', + content: { error }, + }); + } catch (anotherError) { + ctx.body = { error: anotherError.message }; + } } });
packages/server/src/routes/index/upgrade.ts+3 −1 modified@@ -10,6 +10,7 @@ import { bodyFields } from '../../utils/requestUtils'; import { NotificationKey } from '../../models/NotificationModel'; import { AccountType } from '../../models/UserModel'; import { ErrorBadRequest } from '../../utils/errors'; +import { createCsrfTag } from '../../utils/csrf'; interface FormFields { upgrade_button: string; @@ -21,7 +22,7 @@ function upgradeUrl() { return `${config().baseUrl}/upgrade`; } -router.get('upgrade', async (_path: SubPath, _ctx: AppContext) => { +router.get('upgrade', async (_path: SubPath, ctx: AppContext) => { interface PlanRow { basicLabel: string; proLabel: string; @@ -51,6 +52,7 @@ router.get('upgrade', async (_path: SubPath, _ctx: AppContext) => { basicPrice: plans.basic.price, proPrice: plans.pro.price, postUrl: upgradeUrl(), + csrfTag: await createCsrfTag(ctx), }; view.cssFiles = ['index/upgrade']; return view;
packages/server/src/routes/index/users.ts+2 −0 modified@@ -17,6 +17,7 @@ import { getCanShareFolder, totalSizeClass } from '../../models/utils/user'; import { yesNoDefaultOptions } from '../../utils/views/select'; import { confirmUrl } from '../../utils/urlUtils'; import { cancelSubscription, updateSubscriptionType } from '../../utils/stripe'; +import { createCsrfTag } from '../../utils/csrf'; export interface CheckRepeatPasswordInput { password: string; @@ -146,6 +147,7 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null view.content.error = error; view.content.postUrl = postUrl; view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled; + view.content.csrfTag = await createCsrfTag(ctx); if (subscription) { view.content.subscription = subscription;
packages/server/src/utils/csrf.ts+37 −0 added@@ -0,0 +1,37 @@ +import { ErrorForbidden } from './errors'; +import { escapeHtml } from './htmlUtils'; +import { bodyFields, isApiRequest } from './requestUtils'; +import { AppContext } from './types'; + +interface BodyWithCsrfToken { + _csrf: string; +} + +export async function csrfCheck(ctx: AppContext, isPublicRoute: boolean) { + if (isApiRequest(ctx)) return; + if (isPublicRoute) return; + if (!['POST', 'PUT'].includes(ctx.method)) return; + if (ctx.path === '/logout') return; + + const userId = ctx.joplin.owner ? ctx.joplin.owner.id : ''; + if (!userId) return; + + const fields = await bodyFields<BodyWithCsrfToken>(ctx.req); + if (!fields._csrf) throw new ErrorForbidden('CSRF token is missing'); + + if (!(await ctx.joplin.models.token().isValid(userId, fields._csrf))) { + throw new ErrorForbidden(`Invalid CSRF token: ${fields._csrf}`); + } + + await ctx.joplin.models.token().deleteByValue(userId, fields._csrf); +} + +export async function createCsrfToken(ctx: AppContext) { + if (!ctx.joplin.owner) throw new Error('Cannot create CSRF token without a user'); + return ctx.joplin.models.token().generate(ctx.joplin.owner.id); +} + +export async function createCsrfTag(ctx: AppContext) { + const token = await createCsrfToken(ctx); + return `<input type="hidden" name="_csrf" value="${escapeHtml(token)}"/>`; +}
packages/server/src/utils/requestUtils.ts+9 −1 modified@@ -22,6 +22,8 @@ export async function formParse(req: any): Promise<FormParseResult> { return output; } + if (req.__parsed) return req.__parsed; + // Note that for Formidable to work, the content-type must be set in the // headers return new Promise((resolve: Function, reject: Function) => { @@ -32,7 +34,13 @@ export async function formParse(req: any): Promise<FormParseResult> { return; } - resolve({ fields, files }); + // Formidable seems to be doing some black magic and once a request + // has been parsed it cannot be parsed again. Doing so will do + // nothing, the code will just end there, or maybe wait + // indefinitely. So we cache the result on success and return it if + // some code somewhere tries again to parse the form. + req.__parsed = { fields, files }; + resolve(req.__parsed); }); }); }
packages/server/src/utils/routeUtils.ts+6 −1 modified@@ -4,6 +4,7 @@ import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors'; import Router from './Router'; import { AppContext, HttpMethod, RouteType } from './types'; import { URL } from 'url'; +import { csrfCheck } from './csrf'; const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils'); @@ -188,10 +189,14 @@ export async function execRequest(routes: Routers, ctx: AppContext) { const endPoint = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema); if (ctx.URL && !isValidOrigin(ctx.URL.origin, baseUrl(endPoint.type), endPoint.type)) throw new ErrorNotFound(`Invalid origin: ${ctx.URL.origin}`, 'invalidOrigin'); + const isPublicRoute = match.route.isPublic(match.subPath.schema); + // This is a generic catch-all for all private end points - if we // couldn't get a valid session, we exit now. Individual end points // might have additional permission checks depending on the action. - if (!match.route.isPublic(match.subPath.schema) && !ctx.joplin.owner) throw new ErrorForbidden(); + if (!isPublicRoute && !ctx.joplin.owner) throw new ErrorForbidden(); + + await csrfCheck(ctx, isPublicRoute); return endPoint.handler(match.subPath, ctx); }
packages/server/src/views/index/upgrade.mustache+2 −1 modified@@ -1,7 +1,8 @@ <h1 class="title">Upgrade your account</h1> -<p class="subtitle">Upgrading to a Pro account to get the following benefits.</p> +<p class="subtitle">Upgrade to a Pro account to get the following benefits.</p> <form id="upgrade_form" action="{{{postUrl}}}" method="POST"> + {{{csrfTag}}} <table class="table is-hoverable user-props-table"> <tbody> <tr>
packages/server/src/views/index/user.mustache+10 −11 modified@@ -4,6 +4,7 @@ <div class="block"> {{> errorBanner}} + {{{csrfTag}}} <input type="hidden" name="id" value="{{user.id}}"/> <input type="hidden" name="is_new" value="{{isNew}}"/> <div class="field"> @@ -94,11 +95,11 @@ </div> </div> - <h1 class="title">Your subscription</h1> + {{#subscription}} + <h1 class="title">Your subscription</h1> - <div class="block"> - {{#global.owner.is_admin}} - {{#subscription}} + <div class="block"> + {{#global.owner.is_admin}} <div class="control block"> <p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p> {{#showUpdateSubscriptionBasic}} @@ -111,11 +112,9 @@ <input type="submit" name="cancel_subscription_button" class="button is-danger" value="Cancel subscription" /> {{/showCancelSubscription}} </div> - {{/subscription}} - {{/global.owner.is_admin}} + {{/global.owner.is_admin}} - {{^global.owner.is_admin}} - {{#subscription}} + {{^global.owner.is_admin}} <div class="control block"> {{#showUpdateSubscriptionPro}} <a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a> @@ -125,9 +124,9 @@ <input type="submit" id="user_cancel_subscription_button" name="user_cancel_subscription_button" class="button is-danger" value="Cancel subscription" /> {{/showCancelSubscription}} </div> - {{/subscription}} - {{/global.owner.is_admin}} - </div> + {{/global.owner.is_admin}} + </div> + {{/subscription}} </form>
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-gjwp-7v3g-99pjghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-23431ghsaADVISORY
- github.com/laurent22/joplin/commit/19b45de2981c09f6f387498ef96d32b4811eba5eghsax_refsource_MISCWEB
- snyk.io/vuln/SNYK-JS-JOPLIN-1325537ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.