Merge pull request #3351 from SillyTavern/csrf-sync Replace CSRF middleware
Signed| @@ -70,7 +70,7 @@ perUserBasicAuth: false | |||
| 70 | ## Set to a positive number to expire session after a certain time of inactivity | 70 | ## Set to a positive number to expire session after a certain time of inactivity |
| 71 | ## Set to 0 to expire session when the browser is closed | 71 | ## Set to 0 to expire session when the browser is closed |
| 72 | ## Set to a negative number to disable session expiration | 72 | ## Set to a negative number to disable session expiration |
| 73 | sessionTimeout: 86400 | 73 | sessionTimeout: -1 |
| 74 | # Used to sign session cookies. Will be auto-generated if not set | 74 | # Used to sign session cookies. Will be auto-generated if not set |
| 75 | cookieSecret: '' | 75 | cookieSecret: '' |
| 76 | # Disable CSRF protection - NOT RECOMMENDED | 76 | # Disable CSRF protection - NOT RECOMMENDED |
| @@ -1,6 +1,24 @@ | |||
| 1 | import { UserDirectoryList, User } from "./src/users"; | 1 | import { UserDirectoryList, User } from "./src/users"; |
| 2 | import { CsrfSyncedToken } from "csrf-sync"; | ||
| 2 | 3 | ||
| 3 | declare global { | 4 | declare global { |
| 5 | declare namespace CookieSessionInterfaces { | ||
| 6 | export interface CookieSessionObject { | ||
| 7 | /** | ||
| 8 | * The CSRF token for the session. | ||
| 9 | */ | ||
| 10 | csrfToken: CsrfSyncedToken; | ||
| 11 | /** | ||
| 12 | * Authenticated user handle. | ||
| 13 | */ | ||
| 14 | handle: string; | ||
| 15 | /** | ||
| 16 | * Last time the session was extended. | ||
| 17 | */ | ||
| 18 | touch: number; | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 4 | namespace Express { | 22 | namespace Express { |
| 5 | export interface Request { | 23 | export interface Request { |
| 6 | user: { | 24 | user: { |
| @@ -15,11 +33,3 @@ declare global { | |||
| 15 | */ | 33 | */ |
| 16 | var DATA_ROOT: string; | 34 | var DATA_ROOT: string; |
| 17 | } | 35 | } |
| 18 | |||
| 19 | declare module 'express-session' { | ||
| 20 | export interface SessionData { | ||
| 21 | handle: string; | ||
| 22 | touch: number; | ||
| 23 | // other properties... | ||
| 24 | } | ||
| 25 | } | ||
| @@ -26,7 +26,7 @@ | |||
| 26 | "cookie-parser": "^1.4.6", | 26 | "cookie-parser": "^1.4.6", |
| 27 | "cookie-session": "^2.1.0", | 27 | "cookie-session": "^2.1.0", |
| 28 | "cors": "^2.8.5", | 28 | "cors": "^2.8.5", |
| 29 | "csrf-csrf": "^2.2.3", | 29 | "csrf-sync": "^4.0.3", |
| 30 | "diff-match-patch": "^1.0.5", | 30 | "diff-match-patch": "^1.0.5", |
| 31 | "dompurify": "^3.1.7", | 31 | "dompurify": "^3.1.7", |
| 32 | "droll": "^0.2.1", | 32 | "droll": "^0.2.1", |
| @@ -2987,10 +2987,10 @@ | |||
| 2987 | "node": "*" | 2987 | "node": "*" |
| 2988 | } | 2988 | } |
| 2989 | }, | 2989 | }, |
| 2990 | "node_modules/csrf-csrf": { | 2990 | "node_modules/csrf-sync": { |
| 2991 | "version": "2.2.4", | 2991 | "version": "4.0.3", |
| 2992 | "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-2.2.4.tgz", | 2992 | "resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.0.3.tgz", |
| 2993 | "integrity": "sha512-LuhBmy5RfRmEfeqeYqgaAuS1eDpVtKZB/Eiec9xiKQLBynJxrGVRdM2yRT/YMl1Njo/yKh2L9AYsIwSlTPnx2A==", | 2993 | "integrity": "sha512-wXzltBBzt/7imzDt6ZT7G/axQG7jo4Sm0uXDUzFY8hR59qhDHdjqpW2hojS4oAVIZDzwlMQloIVCTJoDDh0wwA==", |
| 2994 | "license": "ISC", | 2994 | "license": "ISC", |
| 2995 | "dependencies": { | 2995 | "dependencies": { |
| 2996 | "http-errors": "^2.0.0" | 2996 | "http-errors": "^2.0.0" |
| @@ -16,7 +16,7 @@ | |||
| 16 | "cookie-parser": "^1.4.6", | 16 | "cookie-parser": "^1.4.6", |
| 17 | "cookie-session": "^2.1.0", | 17 | "cookie-session": "^2.1.0", |
| 18 | "cors": "^2.8.5", | 18 | "cors": "^2.8.5", |
| 19 | "csrf-csrf": "^2.2.3", | 19 | "csrf-sync": "^4.0.3", |
| 20 | "diff-match-patch": "^1.0.5", | 20 | "diff-match-patch": "^1.0.5", |
| 21 | "dompurify": "^3.1.7", | 21 | "dompurify": "^3.1.7", |
| 22 | "droll": "^0.2.1", | 22 | "droll": "^0.2.1", |
| @@ -18,10 +18,9 @@ import { hideBin } from 'yargs/helpers'; | |||
| 18 | 18 | ||
| 19 | // express/server related library imports | 19 | // express/server related library imports |
| 20 | import cors from 'cors'; | 20 | import cors from 'cors'; |
| 21 | import { doubleCsrf } from 'csrf-csrf'; | 21 | import { csrfSync } from 'csrf-sync'; |
| 22 | import express from 'express'; | 22 | import express from 'express'; |
| 23 | import compression from 'compression'; | 23 | import compression from 'compression'; |
| 24 | import cookieParser from 'cookie-parser'; | ||
| 25 | import cookieSession from 'cookie-session'; | 24 | import cookieSession from 'cookie-session'; |
| 26 | import multer from 'multer'; | 25 | import multer from 'multer'; |
| 27 | import responseTime from 'response-time'; | 26 | import responseTime from 'response-time'; |
| @@ -40,7 +39,6 @@ util.inspect.defaultOptions.depth = 4; | |||
| 40 | import { loadPlugins } from './src/plugin-loader.js'; | 39 | import { loadPlugins } from './src/plugin-loader.js'; |
| 41 | import { | 40 | import { |
| 42 | initUserStorage, | 41 | initUserStorage, |
| 43 | getCsrfSecret, | ||
| 44 | getCookieSecret, | 42 | getCookieSecret, |
| 45 | getCookieSessionName, | 43 | getCookieSessionName, |
| 46 | getAllEnabledUsers, | 44 | getAllEnabledUsers, |
| @@ -348,8 +346,8 @@ if (enableCorsProxy) { | |||
| 348 | } | 346 | } |
| 349 | 347 | ||
| 350 | function getSessionCookieAge() { | 348 | function getSessionCookieAge() { |
| 351 | // Defaults to 24 hours in seconds if not set | 349 | // Defaults to "no expiration" if not set |
| 352 | const configValue = getConfigValue('sessionTimeout', 24 * 60 * 60); | 350 | const configValue = getConfigValue('sessionTimeout', -1); |
| 353 | 351 | ||
| 354 | // Convert to milliseconds | 352 | // Convert to milliseconds |
| 355 | if (configValue > 0) { | 353 | if (configValue > 0) { |
| @@ -378,27 +376,38 @@ app.use(setUserDataMiddleware); | |||
| 378 | 376 | ||
| 379 | // CSRF Protection // | 377 | // CSRF Protection // |
| 380 | if (!disableCsrf) { | 378 | if (!disableCsrf) { |
| 381 | const COOKIES_SECRET = getCookieSecret(); | 379 | const csrfSyncProtection = csrfSync({ |
| 382 | 380 | getTokenFromState: (req) => { | |
| 383 | const { generateToken, doubleCsrfProtection } = doubleCsrf({ | 381 | if (!req.session) { |
| 384 | getSecret: getCsrfSecret, | 382 | console.error('(CSRF error) getTokenFromState: Session object not initialized'); |
| 385 | cookieName: 'X-CSRF-Token', | 383 | return; |
| 386 | cookieOptions: { | 384 | } |
| 387 | sameSite: 'strict', | 385 | return req.session.csrfToken; |
| 388 | secure: false, | ||
| 389 | }, | 386 | }, |
| 390 | size: 64, | 387 | getTokenFromRequest: (req) => { |
| 391 | getTokenFromRequest: (req) => req.headers['x-csrf-token'], | 388 | return req.headers['x-csrf-token']?.toString(); |
| 389 | }, | ||
| 390 | storeTokenInState: (req, token) => { | ||
| 391 | if (!req.session) { | ||
| 392 | console.error('(CSRF error) storeTokenInState: Session object not initialized'); | ||
| 393 | return; | ||
| 394 | } | ||
| 395 | req.session.csrfToken = token; | ||
| 396 | }, | ||
| 397 | size: 32, | ||
| 392 | }); | 398 | }); |
| 393 | 399 | ||
| 394 | app.get('/csrf-token', (req, res) => { | 400 | app.get('/csrf-token', (req, res) => { |
| 395 | res.json({ | 401 | res.json({ |
| 396 | 'token': generateToken(res, req), | 402 | 'token': csrfSyncProtection.generateToken(req), |
| 397 | }); | 403 | }); |
| 398 | }); | 404 | }); |
| 399 | 405 | ||
| 400 | app.use(cookieParser(COOKIES_SECRET)); | 406 | // Customize the error message |
| 401 | app.use(doubleCsrfProtection); | 407 | csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); |
| 408 | csrfSyncProtection.invalidCsrfTokenError.stack = undefined; | ||
| 409 | |||
| 410 | app.use(csrfSyncProtection.csrfSynchronisedProtection); | ||
| 402 | } else { | 411 | } else { |
| 403 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); | 412 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); |
| 404 | app.get('/csrf-token', (req, res) => { | 413 | app.get('/csrf-token', (req, res) => { |
| @@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => { | |||
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | request.session.handle = null; | 25 | request.session.handle = null; |
| 26 | request.session.csrfToken = null; | ||
| 26 | request.session = null; | 27 | request.session = null; |
| 27 | return response.sendStatus(204); | 28 | return response.sendStatus(204); |
| 28 | } catch (error) { | 29 | } catch (error) { |