CVE-2021-23431 (TypeScript, published 2021-08-24T08:15Z)
Description: value: The package joplin before 2.3.2 are vulnerable to Cross-site Request Forgery (CSRF) due to missing CSRF checks in various forms.
Severity: MEDIUM (CVSS v3 base score: 8.8)
Weakness: CWE-352 \u2014 Cross-Site Request Forgery (CSRF)
The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
Repository: https://github.com/laurent22/joplin
Files changed:
packages/server/src/app.ts
packages/server/src/routes/index/upgrade.ts
packages/server/src/routes/index/users.ts
packages/server/src/utils/csrf.ts
packages/server/src/utils/requestUtils.ts
packages/server/src/utils/routeUtils.ts
packages/server/src/views/index/upgrade.mustache
packages/server/src/views/index/user.mustache
Commit message: Server: Added form tokens to prevent CSRF attacks


Vulnerable code:
			ctx.body = JSON.stringify({ error: error.message });
router.get('upgrade', async (_path: SubPath, _ctx: AppContext) => {
			resolve({ fields, files });
	if (!match.route.isPublic(match.subPath.schema) && !ctx.joplin.owner) throw new ErrorForbidden();
<p class="subtitle">Upgrading to a Pro account to get the following benefits.</p>
	<h1 class="title">Your subscription</h1>
	<div class="block">
		{{#global.owner.is_admin}}
			{{#subscription}}
			{{/subscription}}
		{{/global.owner.is_admin}}
		{{^global.owner.is_admin}}
			{{#subscription}}
			{{/subscription}}
		{{/global.owner.is_admin}}
	</div>

Fixed code:

			// 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 };
			}
import { createCsrfTag } from '../../utils/csrf';
router.get('upgrade', async (_path: SubPath, ctx: AppContext) => {
		csrfTag: await createCsrfTag(ctx),
import { createCsrfTag } from '../../utils/csrf';
	view.content.csrfTag = await createCsrfTag(ctx);
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)}"/>`;
}
	if (req.__parsed) return req.__parsed;

			// 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);
import { csrfCheck } from './csrf';
	const isPublicRoute = match.route.isPublic(match.subPath.schema);

	if (!isPublicRoute && !ctx.joplin.owner) throw new ErrorForbidden();

	await csrfCheck(ctx, isPublicRoute);
<p class="subtitle">Upgrade to a Pro account to get the following benefits.</p>
	{{{csrfTag}}}
		{{{csrfTag}}}
	{{#subscription}}
		<h1 class="title">Your subscription</h1>
		<div class="block">
			{{#global.owner.is_admin}}
			{{/global.owner.is_admin}}
			{{^global.owner.is_admin}}
			{{/global.owner.is_admin}}
		</div>
	{{/subscription}}