1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-04-04 21:35:03 +02:00

Server: Increase cookies security - set HttpOnly, Secure and SameSite flags

This commit is contained in:
Laurent Cozic 2021-08-15 11:09:52 +01:00
parent e0971baec4
commit bcadb3662b
13 changed files with 47 additions and 16 deletions

View File

@ -46,6 +46,8 @@ export interface EnvVariables {
SUPPORT_NAME?: string; SUPPORT_NAME?: string;
BUSINESS_EMAIL?: string; BUSINESS_EMAIL?: string;
COOKIES_SECURE?: string;
} }
let runningInDocker_: boolean = false; let runningInDocker_: boolean = false;
@ -168,6 +170,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
supportEmail, supportEmail,
supportName: env.SUPPORT_NAME || appName, supportName: env.SUPPORT_NAME || appName,
businessEmail: env.BUSINESS_EMAIL || supportEmail, businessEmail: env.BUSINESS_EMAIL || supportEmail,
cookieSecure: env.COOKIES_SECURE === '1',
...overrides, ...overrides,
}; };
} }

View File

@ -1,5 +1,6 @@
import { Session } from '../../db'; import { Session } from '../../db';
import routeHandler from '../../middleware/routeHandler'; import routeHandler from '../../middleware/routeHandler';
import { cookieGet } from '../../utils/cookies';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, parseHtml, createUser } from '../../utils/testing/testUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, parseHtml, createUser } from '../../utils/testing/testUtils';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
@ -52,7 +53,7 @@ describe('index_login', function() {
const user = await createUser(1); const user = await createUser(1);
const context = await doLogin(user.email, '123456'); const context = await doLogin(user.email, '123456');
const sessionId = context.cookies.get('sessionId'); const sessionId = cookieGet(context, 'sessionId');
const session: Session = await models().session().load(sessionId); const session: Session = await models().session().load(sessionId);
expect(session.user_id).toBe(user.id); expect(session.user_id).toBe(user.id);
}); });
@ -62,12 +63,12 @@ describe('index_login', function() {
{ {
const context = await doLogin('bad', '123456'); const context = await doLogin('bad', '123456');
expect(!context.cookies.get('sessionId')).toBe(true); expect(!cookieGet(context, 'sessionId')).toBe(true);
} }
{ {
const context = await doLogin(user.email, 'bad'); const context = await doLogin(user.email, 'bad');
expect(!context.cookies.get('sessionId')).toBe(true); expect(!cookieGet(context, 'sessionId')).toBe(true);
} }
}); });

View File

@ -7,6 +7,7 @@ import config from '../../config';
import defaultView from '../../utils/defaultView'; import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService'; import { View } from '../../services/MustacheService';
import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce'; import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce';
import { cookieSet } from '../../utils/cookies';
function makeView(error: any = null): View { function makeView(error: any = null): View {
const view = defaultView('login', 'Login'); const view = defaultView('login', 'Login');
@ -32,7 +33,7 @@ router.post('login', async (_path: SubPath, ctx: AppContext) => {
const body = await formParse(ctx.req); const body = await formParse(ctx.req);
const session = await ctx.joplin.models.session().authenticate(body.fields.email, body.fields.password); const session = await ctx.joplin.models.session().authenticate(body.fields.email, body.fields.password);
ctx.cookies.set('sessionId', session.id); cookieSet(ctx, 'sessionId', session.id);
return redirect(ctx, `${config().baseUrl}/home`); return redirect(ctx, `${config().baseUrl}/home`);
} catch (error) { } catch (error) {
return makeView(error); return makeView(error);

View File

@ -1,4 +1,5 @@
import routeHandler from '../../middleware/routeHandler'; import routeHandler from '../../middleware/routeHandler';
import { cookieGet } from '../../utils/cookies';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, createUserAndSession } from '../../utils/testing/testUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_logout', function() { describe('index_logout', function() {
@ -26,11 +27,11 @@ describe('index_logout', function() {
}, },
}); });
expect(context.cookies.get('sessionId')).toBe(session.id); expect(cookieGet(context, 'sessionId')).toBe(session.id);
expect(!!(await models().session().load(session.id))).toBe(true); expect(!!(await models().session().load(session.id))).toBe(true);
await routeHandler(context); await routeHandler(context);
expect(!context.cookies.get('sessionId')).toBe(true); expect(!cookieGet(context, 'sessionId')).toBe(true);
expect(!!(await models().session().load(session.id))).toBe(false); expect(!!(await models().session().load(session.id))).toBe(false);
}); });

View File

@ -4,12 +4,13 @@ import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types'; import { AppContext } from '../../utils/types';
import config from '../../config'; import config from '../../config';
import { contextSessionId } from '../../utils/requestUtils'; import { contextSessionId } from '../../utils/requestUtils';
import { cookieSet } from '../../utils/cookies';
const router = new Router(RouteType.Web); const router = new Router(RouteType.Web);
router.post('logout', async (_path: SubPath, ctx: AppContext) => { router.post('logout', async (_path: SubPath, ctx: AppContext) => {
const sessionId = contextSessionId(ctx, false); const sessionId = contextSessionId(ctx, false);
ctx.cookies.set('sessionId', ''); cookieSet(ctx, 'sessionId', '');
await ctx.joplin.models.session().logout(sessionId); await ctx.joplin.models.session().logout(sessionId);
return redirect(ctx, `${config().baseUrl}/login`); return redirect(ctx, `${config().baseUrl}/login`);
}); });

View File

@ -3,6 +3,7 @@ import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel'; import { AccountType } from '../../models/UserModel';
import { getCanShareFolder, getMaxItemSize } from '../../models/utils/user'; import { getCanShareFolder, getMaxItemSize } from '../../models/utils/user';
import { MB } from '../../utils/bytes'; import { MB } from '../../utils/bytes';
import { cookieGet } from '../../utils/cookies';
import { execRequestC } from '../../utils/testing/apiUtils'; import { execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils';
import uuidgen from '../../utils/uuidgen'; import uuidgen from '../../utils/uuidgen';
@ -50,7 +51,7 @@ describe('index_signup', function() {
expect(getMaxItemSize(user)).toBe(10 * MB); expect(getMaxItemSize(user)).toBe(10 * MB);
// Check that the user is logged in // Check that the user is logged in
const session = await models().session().load(context.cookies.get('sessionId')); const session = await models().session().load(cookieGet(context, 'sessionId'));
expect(session.user_id).toBe(user.id); expect(session.user_id).toBe(user.id);
// Check that the notification has been created // Check that the notification has been created

View File

@ -10,6 +10,7 @@ import { checkRepeatPassword } from './users';
import { NotificationKey } from '../../models/NotificationModel'; import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel'; import { AccountType } from '../../models/UserModel';
import { ErrorForbidden } from '../../utils/errors'; import { ErrorForbidden } from '../../utils/errors';
import { cookieSet } from '../../utils/cookies';
function makeView(error: Error = null): View { function makeView(error: Error = null): View {
const view = defaultView('signup', 'Sign Up'); const view = defaultView('signup', 'Sign Up');
@ -51,7 +52,7 @@ router.post('signup', async (_path: SubPath, ctx: AppContext) => {
}); });
const session = await ctx.joplin.models.session().createUserSession(user.id); const session = await ctx.joplin.models.session().createUserSession(user.id);
ctx.cookies.set('sessionId', session.id); cookieSet(ctx, 'sessionId', session.id);
await ctx.joplin.models.notification().add(user.id, NotificationKey.ConfirmEmail); await ctx.joplin.models.notification().add(user.id, NotificationKey.ConfirmEmail);

View File

@ -1,6 +1,7 @@
import { User } from '../../db'; import { User } from '../../db';
import routeHandler from '../../middleware/routeHandler'; import routeHandler from '../../middleware/routeHandler';
import { NotificationKey } from '../../models/NotificationModel'; import { NotificationKey } from '../../models/NotificationModel';
import { cookieGet } from '../../utils/cookies';
import { ErrorForbidden } from '../../utils/errors'; import { ErrorForbidden } from '../../utils/errors';
import { execRequest, execRequestC } from '../../utils/testing/apiUtils'; import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
@ -240,7 +241,7 @@ describe('index/users', function() {
password: newPassword, password: newPassword,
password2: newPassword, password2: newPassword,
}); });
const sessionId = context.cookies.get('sessionId'); const sessionId = cookieGet(context, 'sessionId');
expect(sessionId).toBeFalsy(); expect(sessionId).toBeFalsy();
} }
@ -253,7 +254,7 @@ describe('index/users', function() {
password2: newPassword, password2: newPassword,
token: token2, token: token2,
}); });
const sessionId = context.cookies.get('sessionId'); const sessionId = cookieGet(context, 'sessionId');
expect(sessionId).toBeFalsy(); expect(sessionId).toBeFalsy();
} }
@ -266,7 +267,7 @@ describe('index/users', function() {
}); });
// Check that the user has been logged in // Check that the user has been logged in
const sessionId = context.cookies.get('sessionId'); const sessionId = cookieGet(context, 'sessionId');
const session = await models().session().load(sessionId); const session = await models().session().load(sessionId);
expect(session.user_id).toBe(user1.id); expect(session.user_id).toBe(user1.id);
@ -303,7 +304,7 @@ describe('index/users', function() {
user1 = await models().user().load(user1.id); user1 = await models().user().load(user1.id);
// Check that the user has been logged in // Check that the user has been logged in
const sessionId = context.cookies.get('sessionId'); const sessionId = cookieGet(context, 'sessionId');
expect(sessionId).toBeFalsy(); expect(sessionId).toBeFalsy();
// Check that the email has been verified // Check that the email has been verified

View File

@ -19,6 +19,7 @@ import { confirmUrl } from '../../utils/urlUtils';
import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe'; import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe';
import { createCsrfTag } from '../../utils/csrf'; import { createCsrfTag } from '../../utils/csrf';
import { formatDateTime } from '../../utils/time'; import { formatDateTime } from '../../utils/time';
import { cookieSet } from '../../utils/cookies';
export interface CheckRepeatPasswordInput { export interface CheckRepeatPasswordInput {
password: string; password: string;
@ -236,7 +237,7 @@ router.post('users/:id/confirm', async (path: SubPath, ctx: AppContext) => {
await ctx.joplin.models.token().deleteByValue(userId, fields.token); await ctx.joplin.models.token().deleteByValue(userId, fields.token);
const session = await ctx.joplin.models.session().createUserSession(userId); const session = await ctx.joplin.models.session().createUserSession(userId);
ctx.cookies.set('sessionId', session.id); cookieSet(ctx, 'sessionId', session.id);
await ctx.joplin.models.notification().add(userId, NotificationKey.PasswordSet); await ctx.joplin.models.notification().add(userId, NotificationKey.PasswordSet);

View File

@ -0,0 +1,17 @@
import config from '../config';
import { AppContext } from './types';
export function cookieSet(ctx: AppContext, name: string, value: string) {
ctx.cookies.set(name, value, {
// Means that the cookies cannot be accessed from JavaScript
httpOnly: true,
// Can only be transferred over https
secure: config().cookieSecure,
// Prevent cookies from being sent in cross-site requests
sameSite: true,
});
}
export function cookieGet(ctx: AppContext, name: string) {
return ctx.cookies.get(name);
}

View File

@ -1,3 +1,4 @@
import { cookieGet } from './cookies';
import { ErrorForbidden } from './errors'; import { ErrorForbidden } from './errors';
import { AppContext } from './types'; import { AppContext } from './types';
@ -61,7 +62,7 @@ export function headerSessionId(headers: any): string {
export function contextSessionId(ctx: AppContext, throwIfNotFound = true): string { export function contextSessionId(ctx: AppContext, throwIfNotFound = true): string {
if (ctx.headers['x-api-auth']) return ctx.headers['x-api-auth']; if (ctx.headers['x-api-auth']) return ctx.headers['x-api-auth'];
const id = ctx.cookies.get('sessionId'); const id = cookieGet(ctx, 'sessionId');
if (!id && throwIfNotFound) throw new ErrorForbidden('Invalid or missing session'); if (!id && throwIfNotFound) throw new ErrorForbidden('Invalid or missing session');
return id; return id;
} }

View File

@ -21,6 +21,7 @@ import { initializeJoplinUtils } from '../joplinUtils';
import MustacheService from '../../services/MustacheService'; import MustacheService from '../../services/MustacheService';
import uuidgen from '../uuidgen'; import uuidgen from '../uuidgen';
import { createCsrfToken } from '../csrf'; import { createCsrfToken } from '../csrf';
import { cookieSet } from '../cookies';
// Takes into account the fact that this file will be inside the /dist directory // Takes into account the fact that this file will be inside the /dist directory
// when it runs. // when it runs.
@ -211,7 +212,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
}; };
if (options.sessionId) { if (options.sessionId) {
appContext.cookies.set('sessionId', options.sessionId); cookieSet(appContext, 'sessionId', options.sessionId);
} }
return appContext as AppContext; return appContext as AppContext;

View File

@ -108,6 +108,7 @@ export interface Config {
supportName: string; supportName: string;
businessEmail: string; businessEmail: string;
isJoplinCloud: boolean; isJoplinCloud: boolean;
cookieSecure: boolean;
} }
export enum HttpMethod { export enum HttpMethod {