Move security checks to users.js
| @@ -1,4 +1,5 @@ | |||
| 1 | import { UserDirectoryList, User } from "./src/users"; | 1 | import { UserDirectoryList, User } from "./src/users"; |
| 2 | import { CommandLineArguments } from "./src/command-line"; | ||
| 2 | import { CsrfSyncedToken } from "csrf-sync"; | 3 | import { CsrfSyncedToken } from "csrf-sync"; |
| 3 | 4 | ||
| 4 | declare global { | 5 | declare global { |
| @@ -32,4 +33,9 @@ declare global { | |||
| 32 | * The root directory for user data. | 33 | * The root directory for user data. |
| 33 | */ | 34 | */ |
| 34 | var DATA_ROOT: string; | 35 | var DATA_ROOT: string; |
| 36 | |||
| 37 | /** | ||
| 38 | * Parsed command line arguments. | ||
| 39 | */ | ||
| 40 | var COMMAND_LINE_ARGS: CommandLineArguments; | ||
| 35 | } | 41 | } |
| @@ -26,7 +26,6 @@ import { | |||
| 26 | initUserStorage, | 26 | initUserStorage, |
| 27 | getCookieSecret, | 27 | getCookieSecret, |
| 28 | getCookieSessionName, | 28 | getCookieSessionName, |
| 29 | getAllEnabledUsers, | ||
| 30 | ensurePublicDirectoriesExist, | 29 | ensurePublicDirectoriesExist, |
| 31 | getUserDirectoriesList, | 30 | getUserDirectoriesList, |
| 32 | migrateSystemPrompts, | 31 | migrateSystemPrompts, |
| @@ -34,9 +33,10 @@ import { | |||
| 34 | requireLoginMiddleware, | 33 | requireLoginMiddleware, |
| 35 | setUserDataMiddleware, | 34 | setUserDataMiddleware, |
| 36 | shouldRedirectToLogin, | 35 | shouldRedirectToLogin, |
| 37 | tryAutoLogin, | ||
| 38 | cleanUploads, | 36 | cleanUploads, |
| 39 | getSessionCookieAge, | 37 | getSessionCookieAge, |
| 38 | verifySecuritySettings, | ||
| 39 | loginPageMiddleware, | ||
| 40 | } from './src/users.js'; | 40 | } from './src/users.js'; |
| 41 | 41 | ||
| 42 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; | 42 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; |
| @@ -49,7 +49,6 @@ import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; | |||
| 49 | import corsProxyMiddleware from './src/middleware/corsProxy.js'; | 49 | import corsProxyMiddleware from './src/middleware/corsProxy.js'; |
| 50 | import { | 50 | import { |
| 51 | getVersion, | 51 | getVersion, |
| 52 | getConfigValue, | ||
| 53 | color, | 52 | color, |
| 54 | removeColorFormatting, | 53 | removeColorFormatting, |
| 55 | getSeparator, | 54 | getSeparator, |
| @@ -72,6 +71,11 @@ util.inspect.defaultOptions.maxArrayLength = null; | |||
| 72 | util.inspect.defaultOptions.maxStringLength = null; | 71 | util.inspect.defaultOptions.maxStringLength = null; |
| 73 | util.inspect.defaultOptions.depth = 4; | 72 | util.inspect.defaultOptions.depth = 4; |
| 74 | 73 | ||
| 74 | // Set a working directory for the server | ||
| 75 | const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); | ||
| 76 | console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); | ||
| 77 | process.chdir(serverDirectory); | ||
| 78 | |||
| 75 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. | 79 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. |
| 76 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 | 80 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 |
| 77 | // Safe to remove once support for Node v20 is dropped. | 81 | // Safe to remove once support for Node v20 is dropped. |
| @@ -80,19 +84,26 @@ if (process.versions && process.versions.node && process.versions.node.match(/20 | |||
| 80 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); | 84 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); |
| 81 | } | 85 | } |
| 82 | 86 | ||
| 83 | const cliParser = new CommandLineParser(); | 87 | const cliArgs = new CommandLineParser().parse(process.argv); |
| 84 | const cliArgs = cliParser.parse(process.argv); | ||
| 85 | globalThis.DATA_ROOT = cliArgs.dataRoot; | 88 | globalThis.DATA_ROOT = cliArgs.dataRoot; |
| 89 | globalThis.COMMAND_LINE_ARGS = cliArgs; | ||
| 86 | 90 | ||
| 87 | if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { | 91 | if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { |
| 88 | console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); | 92 | console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); |
| 89 | process.exit(1); | 93 | process.exit(1); |
| 90 | } | 94 | } |
| 91 | 95 | ||
| 92 | // change all relative paths | 96 | try { |
| 93 | const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); | 97 | if (cliArgs.dnsPreferIPv6) { |
| 94 | console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); | 98 | dns.setDefaultResultOrder('ipv6first'); |
| 95 | process.chdir(serverDirectory); | 99 | console.log('Preferring IPv6 for DNS resolution'); |
| 100 | } else { | ||
| 101 | dns.setDefaultResultOrder('ipv4first'); | ||
| 102 | console.log('Preferring IPv4 for DNS resolution'); | ||
| 103 | } | ||
| 104 | } catch (error) { | ||
| 105 | console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.'); | ||
| 106 | } | ||
| 96 | 107 | ||
| 97 | const app = express(); | 108 | const app = express(); |
| 98 | app.use(helmet({ | 109 | app.use(helmet({ |
| @@ -101,19 +112,6 @@ app.use(helmet({ | |||
| 101 | app.use(compression()); | 112 | app.use(compression()); |
| 102 | app.use(responseTime()); | 113 | app.use(responseTime()); |
| 103 | 114 | ||
| 104 | /** @type {boolean} */ | ||
| 105 | const enableAccounts = getConfigValue('enableUserAccounts', false, 'boolean'); | ||
| 106 | |||
| 107 | if (cliArgs.dnsPreferIPv6) { | ||
| 108 | // Set default DNS resolution order to IPv6 first | ||
| 109 | dns.setDefaultResultOrder('ipv6first'); | ||
| 110 | console.log('Preferring IPv6 for DNS resolution'); | ||
| 111 | } else { | ||
| 112 | // Set default DNS resolution order to IPv4 first | ||
| 113 | dns.setDefaultResultOrder('ipv4first'); | ||
| 114 | console.log('Preferring IPv4 for DNS resolution'); | ||
| 115 | } | ||
| 116 | |||
| 117 | // CORS Settings // | 115 | // CORS Settings // |
| 118 | const CORS = cors({ | 116 | const CORS = cors({ |
| 119 | origin: 'null', | 117 | origin: 'null', |
| @@ -213,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => { | |||
| 213 | }); | 211 | }); |
| 214 | 212 | ||
| 215 | // Host login page | 213 | // Host login page |
| 216 | app.get('/login', async (request, response) => { | 214 | app.get('/login', loginPageMiddleware); |
| 217 | if (!enableAccounts) { | ||
| 218 | console.log('User accounts are disabled. Redirecting to index page.'); | ||
| 219 | return response.redirect('/'); | ||
| 220 | } | ||
| 221 | |||
| 222 | try { | ||
| 223 | const autoLogin = await tryAutoLogin(request, cliArgs.basicAuthMode); | ||
| 224 | |||
| 225 | if (autoLogin) { | ||
| 226 | return response.redirect('/'); | ||
| 227 | } | ||
| 228 | } catch (error) { | ||
| 229 | console.error('Error during auto-login:', error); | ||
| 230 | } | ||
| 231 | |||
| 232 | return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); | ||
| 233 | }); | ||
| 234 | 215 | ||
| 235 | // Host frontend assets | 216 | // Host frontend assets |
| 236 | const webpackMiddleware = getWebpackServeMiddleware(); | 217 | const webpackMiddleware = getWebpackServeMiddleware(); |
| @@ -289,7 +270,8 @@ async function preSetupTasks() { | |||
| 289 | await settingsInit(); | 270 | await settingsInit(); |
| 290 | await statsInit(); | 271 | await statsInit(); |
| 291 | 272 | ||
| 292 | const cleanupPlugins = await initializePlugins(); | 273 | const pluginsDirectory = path.join(serverDirectory, 'plugins'); |
| 274 | const cleanupPlugins = await loadPlugins(app, pluginsDirectory); | ||
| 293 | const consoleTitle = process.title; | 275 | const consoleTitle = process.title; |
| 294 | 276 | ||
| 295 | let isExiting = false; | 277 | let isExiting = false; |
| @@ -366,80 +348,6 @@ async function postSetupTasks(result) { | |||
| 366 | } | 348 | } |
| 367 | 349 | ||
| 368 | /** | 350 | /** |
| 369 | * Loads server plugins from a directory. | ||
| 370 | * @returns {Promise<Function>} Function to be run on server exit | ||
| 371 | */ | ||
| 372 | async function initializePlugins() { | ||
| 373 | try { | ||
| 374 | const pluginDirectory = path.join(serverDirectory, 'plugins'); | ||
| 375 | const cleanupPlugins = await loadPlugins(app, pluginDirectory); | ||
| 376 | return cleanupPlugins; | ||
| 377 | } catch { | ||
| 378 | console.log('Plugin loading failed.'); | ||
| 379 | return () => { }; | ||
| 380 | } | ||
| 381 | } | ||
| 382 | |||
| 383 | /** | ||
| 384 | * Prints an error message and exits the process if necessary | ||
| 385 | * @param {string} message The error message to print | ||
| 386 | * @returns {void} | ||
| 387 | */ | ||
| 388 | function logSecurityAlert(message) { | ||
| 389 | if (cliArgs.basicAuthMode || cliArgs.whitelistMode) return; // safe! | ||
| 390 | console.error(color.red(message)); | ||
| 391 | if (getConfigValue('securityOverride', false, 'boolean')) { | ||
| 392 | console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); | ||
| 393 | return; | ||
| 394 | } | ||
| 395 | process.exit(1); | ||
| 396 | } | ||
| 397 | |||
| 398 | async function verifySecuritySettings() { | ||
| 399 | // Skip all security checks as listen is set to false | ||
| 400 | if (!cliArgs.listen) { | ||
| 401 | return; | ||
| 402 | } | ||
| 403 | |||
| 404 | if (!enableAccounts) { | ||
| 405 | logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.'); | ||
| 406 | } | ||
| 407 | |||
| 408 | const users = await getAllEnabledUsers(); | ||
| 409 | const unprotectedUsers = users.filter(x => !x.password); | ||
| 410 | const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin); | ||
| 411 | |||
| 412 | if (unprotectedUsers.length > 0) { | ||
| 413 | console.warn(color.blue('A friendly reminder that the following users are not password protected:')); | ||
| 414 | unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x)); | ||
| 415 | console.log(); | ||
| 416 | console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`); | ||
| 417 | console.log(); | ||
| 418 | |||
| 419 | if (unprotectedAdminUsers.length > 0) { | ||
| 420 | logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.'); | ||
| 421 | } | ||
| 422 | } | ||
| 423 | |||
| 424 | if (cliArgs.basicAuthMode) { | ||
| 425 | const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean'); | ||
| 426 | if (perUserBasicAuth && !enableAccounts) { | ||
| 427 | console.error(color.red( | ||
| 428 | 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.', | ||
| 429 | )); | ||
| 430 | } else if (!perUserBasicAuth) { | ||
| 431 | const basicAuthUserName = getConfigValue('basicAuthUser.username', ''); | ||
| 432 | const basicAuthUserPassword = getConfigValue('basicAuthUser.password', ''); | ||
| 433 | if (!basicAuthUserName || !basicAuthUserPassword) { | ||
| 434 | console.warn(color.yellow( | ||
| 435 | 'Basic Authentication is enabled, but username or password is not set or empty!', | ||
| 436 | )); | ||
| 437 | } | ||
| 438 | } | ||
| 439 | } | ||
| 440 | } | ||
| 441 | |||
| 442 | /** | ||
| 443 | * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. | 351 | * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. |
| 444 | */ | 352 | */ |
| 445 | function apply404Middleware() { | 353 | function apply404Middleware() { |
| @@ -38,50 +38,55 @@ const isESModule = (file) => path.extname(file) === '.mjs'; | |||
| 38 | * be called before the server shuts down. | 38 | * be called before the server shuts down. |
| 39 | */ | 39 | */ |
| 40 | export async function loadPlugins(app, pluginsPath) { | 40 | export async function loadPlugins(app, pluginsPath) { |
| 41 | const exitHooks = []; | 41 | try { |
| 42 | const emptyFn = () => { }; | 42 | const exitHooks = []; |
| 43 | const emptyFn = () => { }; | ||
| 43 | 44 | ||
| 44 | // Server plugins are disabled. | 45 | // Server plugins are disabled. |
| 45 | if (!enableServerPlugins) { | 46 | if (!enableServerPlugins) { |
| 46 | return emptyFn; | 47 | return emptyFn; |
| 47 | } | 48 | } |
| 48 | 49 | ||
| 49 | // Plugins directory does not exist. | 50 | // Plugins directory does not exist. |
| 50 | if (!fs.existsSync(pluginsPath)) { | 51 | if (!fs.existsSync(pluginsPath)) { |
| 51 | return emptyFn; | 52 | return emptyFn; |
| 52 | } | 53 | } |
| 53 | 54 | ||
| 54 | const files = fs.readdirSync(pluginsPath); | 55 | const files = fs.readdirSync(pluginsPath); |
| 55 | 56 | ||
| 56 | // No plugins to load. | 57 | // No plugins to load. |
| 57 | if (files.length === 0) { | 58 | if (files.length === 0) { |
| 58 | return emptyFn; | 59 | return emptyFn; |
| 59 | } | 60 | } |
| 60 | 61 | ||
| 61 | await updatePlugins(pluginsPath); | 62 | await updatePlugins(pluginsPath); |
| 62 | 63 | ||
| 63 | for (const file of files) { | 64 | for (const file of files) { |
| 64 | const pluginFilePath = path.join(pluginsPath, file); | 65 | const pluginFilePath = path.join(pluginsPath, file); |
| 65 | 66 | ||
| 66 | if (fs.statSync(pluginFilePath).isDirectory()) { | 67 | if (fs.statSync(pluginFilePath).isDirectory()) { |
| 67 | await loadFromDirectory(app, pluginFilePath, exitHooks); | 68 | await loadFromDirectory(app, pluginFilePath, exitHooks); |
| 68 | continue; | 69 | continue; |
| 69 | } | 70 | } |
| 71 | |||
| 72 | // Not a JavaScript file. | ||
| 73 | if (!isCommonJS(file) && !isESModule(file)) { | ||
| 74 | continue; | ||
| 75 | } | ||
| 70 | 76 | ||
| 71 | // Not a JavaScript file. | 77 | await loadFromFile(app, pluginFilePath, exitHooks); |
| 72 | if (!isCommonJS(file) && !isESModule(file)) { | ||
| 73 | continue; | ||
| 74 | } | 78 | } |
| 75 | 79 | ||
| 76 | await loadFromFile(app, pluginFilePath, exitHooks); | 80 | if (loadedPlugins.size > 0) { |
| 77 | } | 81 | console.log(`${loadedPlugins.size} server plugin(s) are currently loaded. Make sure you know exactly what they do, and only install plugins from trusted sources!`); |
| 82 | } | ||
| 78 | 83 | ||
| 79 | if (loadedPlugins.size > 0) { | 84 | // Call all plugin "exit" functions at once and wait for them to finish |
| 80 | console.log(`${loadedPlugins.size} server plugin(s) are currently loaded. Make sure you know exactly what they do, and only install plugins from trusted sources!`); | 85 | return () => Promise.all(exitHooks.map(exitFn => exitFn())); |
| 86 | } catch (error) { | ||
| 87 | console.error('Plugin loading failed.', error); | ||
| 88 | return () => { }; | ||
| 81 | } | 89 | } |
| 82 | |||
| 83 | // Call all plugin "exit" functions at once and wait for them to finish | ||
| 84 | return () => Promise.all(exitHooks.map(exitFn => exitFn())); | ||
| 85 | } | 90 | } |
| 86 | 91 | ||
| 87 | async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) { | 92 | async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) { |
| @@ -120,6 +120,72 @@ export async function ensurePublicDirectoriesExist() { | |||
| 120 | return directoriesList; | 120 | return directoriesList; |
| 121 | } | 121 | } |
| 122 | 122 | ||
| 123 | /** | ||
| 124 | * Prints an error message and exits the process if necessary | ||
| 125 | * @param {string} message The error message to print | ||
| 126 | * @returns {void} | ||
| 127 | */ | ||
| 128 | function logSecurityAlert(message) { | ||
| 129 | const { basicAuthMode, whitelistMode } = globalThis.COMMAND_LINE_ARGS; | ||
| 130 | if (basicAuthMode || whitelistMode) return; // safe! | ||
| 131 | console.error(color.red(message)); | ||
| 132 | if (getConfigValue('securityOverride', false, 'boolean')) { | ||
| 133 | console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | process.exit(1); | ||
| 137 | } | ||
| 138 | |||
| 139 | /** | ||
| 140 | * Verifies the security settings and prints warnings if necessary | ||
| 141 | * @returns {Promise<void>} | ||
| 142 | */ | ||
| 143 | export async function verifySecuritySettings() { | ||
| 144 | const { listen, basicAuthMode } = globalThis.COMMAND_LINE_ARGS; | ||
| 145 | |||
| 146 | // Skip all security checks as listen is set to false | ||
| 147 | if (!listen) { | ||
| 148 | return; | ||
| 149 | } | ||
| 150 | |||
| 151 | if (!ENABLE_ACCOUNTS) { | ||
| 152 | logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.'); | ||
| 153 | } | ||
| 154 | |||
| 155 | const users = await getAllEnabledUsers(); | ||
| 156 | const unprotectedUsers = users.filter(x => !x.password); | ||
| 157 | const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin); | ||
| 158 | |||
| 159 | if (unprotectedUsers.length > 0) { | ||
| 160 | console.warn(color.blue('A friendly reminder that the following users are not password protected:')); | ||
| 161 | unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x)); | ||
| 162 | console.log(); | ||
| 163 | console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`); | ||
| 164 | console.log(); | ||
| 165 | |||
| 166 | if (unprotectedAdminUsers.length > 0) { | ||
| 167 | logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.'); | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | if (basicAuthMode) { | ||
| 172 | const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean'); | ||
| 173 | if (perUserBasicAuth && !ENABLE_ACCOUNTS) { | ||
| 174 | console.error(color.red( | ||
| 175 | 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.', | ||
| 176 | )); | ||
| 177 | } else if (!perUserBasicAuth) { | ||
| 178 | const basicAuthUserName = getConfigValue('basicAuthUser.username', ''); | ||
| 179 | const basicAuthUserPassword = getConfigValue('basicAuthUser.password', ''); | ||
| 180 | if (!basicAuthUserName || !basicAuthUserPassword) { | ||
| 181 | console.warn(color.yellow( | ||
| 182 | 'Basic Authentication is enabled, but username or password is not set or empty!', | ||
| 183 | )); | ||
| 184 | } | ||
| 185 | } | ||
| 186 | } | ||
| 187 | } | ||
| 188 | |||
| 123 | export function cleanUploads() { | 189 | export function cleanUploads() { |
| 124 | try { | 190 | try { |
| 125 | const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY); | 191 | const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY); |
| @@ -818,6 +884,31 @@ export function requireLoginMiddleware(request, response, next) { | |||
| 818 | } | 884 | } |
| 819 | 885 | ||
| 820 | /** | 886 | /** |
| 887 | * Middleware to host the login page. | ||
| 888 | * @param {import('express').Request} request Request object | ||
| 889 | * @param {import('express').Response} response Response object | ||
| 890 | */ | ||
| 891 | export async function loginPageMiddleware(request, response) { | ||
| 892 | if (!ENABLE_ACCOUNTS) { | ||
| 893 | console.log('User accounts are disabled. Redirecting to index page.'); | ||
| 894 | return response.redirect('/'); | ||
| 895 | } | ||
| 896 | |||
| 897 | try { | ||
| 898 | const { basicAuthMode } = globalThis.COMMAND_LINE_ARGS; | ||
| 899 | const autoLogin = await tryAutoLogin(request, basicAuthMode); | ||
| 900 | |||
| 901 | if (autoLogin) { | ||
| 902 | return response.redirect('/'); | ||
| 903 | } | ||
| 904 | } catch (error) { | ||
| 905 | console.error('Error during auto-login:', error); | ||
| 906 | } | ||
| 907 | |||
| 908 | return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); | ||
| 909 | } | ||
| 910 | |||
| 911 | /** | ||
| 821 | * Creates a route handler for serving files from a specific directory. | 912 | * Creates a route handler for serving files from a specific directory. |
| 822 | * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from | 913 | * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from |
| 823 | * @returns {import('express').RequestHandler} | 914 | * @returns {import('express').RequestHandler} |