Move security checks to users.js
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { UserDirectoryList, User } from "./src/users"; |
| 2 | +import { CommandLineArguments } from "./src/command-line"; | |
| 2 | 3 | import { CsrfSyncedToken } from "csrf-sync"; |
| 3 | 4 | |
| 4 | 5 | declare global { |
| @@ -32,4 +33,9 @@ declare global { | ||
| 32 | 33 | * The root directory for user data. |
| 33 | 34 | */ |
| 34 | 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 | 26 | initUserStorage, |
| 27 | 27 | getCookieSecret, |
| 28 | 28 | getCookieSessionName, |
| 29 | - getAllEnabledUsers, | |
| 30 | 29 | ensurePublicDirectoriesExist, |
| 31 | 30 | getUserDirectoriesList, |
| 32 | 31 | migrateSystemPrompts, |
| @@ -34,9 +33,10 @@ import { | ||
| 34 | 33 | requireLoginMiddleware, |
| 35 | 34 | setUserDataMiddleware, |
| 36 | 35 | shouldRedirectToLogin, |
| 37 | - tryAutoLogin, | |
| 38 | 36 | cleanUploads, |
| 39 | 37 | getSessionCookieAge, |
| 38 | + verifySecuritySettings, | |
| 39 | + loginPageMiddleware, | |
| 40 | 40 | } from './src/users.js'; |
| 41 | 41 | |
| 42 | 42 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; |
| @@ -49,7 +49,6 @@ import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; | ||
| 49 | 49 | import corsProxyMiddleware from './src/middleware/corsProxy.js'; |
| 50 | 50 | import { |
| 51 | 51 | getVersion, |
| 52 | - getConfigValue, | |
| 53 | 52 | color, |
| 54 | 53 | removeColorFormatting, |
| 55 | 54 | getSeparator, |
| @@ -72,6 +71,11 @@ util.inspect.defaultOptions.maxArrayLength = null; | ||
| 72 | 71 | util.inspect.defaultOptions.maxStringLength = null; |
| 73 | 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 | 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 | 80 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 |
| 77 | 81 | // Safe to remove once support for Node v20 is dropped. |
| @@ -80,39 +84,33 @@ if (process.versions && process.versions.node && process.versions.node.match(/20 | ||
| 80 | 84 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); |
| 81 | 85 | } |
| 82 | 86 | |
| 83 | 87 | const cliParsercliArgs = new CommandLineParser().parse(process.argv); |
| 84 | -const cliArgs = cliParser.parse(process.argv); | |
| 85 | 88 | globalThis.DATA_ROOT = cliArgs.dataRoot; |
| 89 | +globalThis.COMMAND_LINE_ARGS = cliArgs; | |
| 86 | 90 | |
| 87 | 91 | if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { |
| 88 | 92 | console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); |
| 89 | 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)); | |
| 94 | -console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); | |
| 95 | -process.chdir(serverDirectory); | |
| 96 | - | |
| 97 | -const app = express(); | |
| 98 | -app.use(helmet({ | |
| 99 | - contentSecurityPolicy: false, | |
| 100 | -})); | |
| 101 | -app.use(compression()); | |
| 102 | -app.use(responseTime()); | |
| 103 | - | |
| 104 | -/** @type {boolean} */ | |
| 105 | -const enableAccounts = getConfigValue('enableUserAccounts', false, 'boolean'); | |
| 106 | - | |
| 107 | 97 | if (cliArgs.dnsPreferIPv6) { |
| 108 | - // Set default DNS resolution order to IPv6 first | |
| 109 | 98 | dns.setDefaultResultOrder('ipv6first'); |
| 110 | 99 | console.log('Preferring IPv6 for DNS resolution'); |
| 111 | 100 | } else { |
| 112 | - // Set default DNS resolution order to IPv4 first | |
| 113 | 101 | dns.setDefaultResultOrder('ipv4first'); |
| 114 | 102 | console.log('Preferring IPv4 for DNS resolution'); |
| 115 | 103 | } |
| 104 | +} catch (error) { | |
| 105 | + console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.'); | |
| 106 | +} | |
| 107 | + | |
| 108 | +const app = express(); | |
| 109 | +app.use(helmet({ | |
| 110 | + contentSecurityPolicy: false, | |
| 111 | +})); | |
| 112 | +app.use(compression()); | |
| 113 | +app.use(responseTime()); | |
| 116 | 114 | |
| 117 | 115 | // CORS Settings // |
| 118 | 116 | const CORS = cors({ |
| @@ -213,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => { | ||
| 213 | 211 | }); |
| 214 | 212 | |
| 215 | 213 | // Host login page |
| 216 | 214 | app.get('/login', async (request, responseloginPageMiddleware) => {; |
| 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 | 216 | // Host frontend assets |
| 236 | 217 | const webpackMiddleware = getWebpackServeMiddleware(); |
| @@ -289,7 +270,8 @@ async function preSetupTasks() { | ||
| 289 | 270 | await settingsInit(); |
| 290 | 271 | await statsInit(); |
| 291 | 272 | |
| 292 | 273 | const cleanupPluginspluginsDirectory = await initializePluginspath.join(serverDirectory, 'plugins'); |
| 274 | + const cleanupPlugins = await loadPlugins(app, pluginsDirectory); | |
| 293 | 275 | const consoleTitle = process.title; |
| 294 | 276 | |
| 295 | 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 | 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 | 353 | function apply404Middleware() { |
| @@ -38,6 +38,7 @@ const isESModule = (file) => path.extname(file) === '.mjs'; | ||
| 38 | 38 | * be called before the server shuts down. |
| 39 | 39 | */ |
| 40 | 40 | export async function loadPlugins(app, pluginsPath) { |
| 41 | + try { | |
| 41 | 42 | const exitHooks = []; |
| 42 | 43 | const emptyFn = () => { }; |
| 43 | 44 | |
| @@ -82,6 +83,10 @@ export async function loadPlugins(app, pluginsPath) { | ||
| 82 | 83 | |
| 83 | 84 | // Call all plugin "exit" functions at once and wait for them to finish |
| 84 | 85 | return () => Promise.all(exitHooks.map(exitFn => exitFn())); |
| 86 | + } catch (error) { | |
| 87 | + console.error('Plugin loading failed.', error); | |
| 88 | + return () => { }; | |
| 89 | + } | |
| 85 | 90 | } |
| 86 | 91 | |
| 87 | 92 | async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) { |
| @@ -120,6 +120,72 @@ export async function ensurePublicDirectoriesExist() { | ||
| 120 | 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 | 189 | export function cleanUploads() { |
| 124 | 190 | try { |
| 125 | 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 | 912 | * Creates a route handler for serving files from a specific directory. |
| 822 | 913 | * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from |
| 823 | 914 | * @returns {import('express').RequestHandler} |