Move security checks to users.js

60448f4ce876245e8eb30efba721b27cba856cfa

Cohee <18619528+Cohee1207@users.noreply.github.com>

4 files changed, +157 -147Ignore whitespace
index.d.ts+6 -0
@@ -1,4 +1,5 @@
11import { UserDirectoryList, User } from "./src/users";
2+import { CommandLineArguments } from "./src/command-line";
23import { CsrfSyncedToken } from "csrf-sync";
34
45declare global {
@@ -32,4 +33,9 @@ declare global {
3233 * The root directory for user data.
3334 */
3435 var DATA_ROOT: string;
36+
37+ /**
38+ * Parsed command line arguments.
39+ */
40+ var COMMAND_LINE_ARGS: CommandLineArguments;
3541}
server.js+23 -115
@@ -26,7 +26,6 @@ import {
2626 initUserStorage,
2727 getCookieSecret,
2828 getCookieSessionName,
29- getAllEnabledUsers,
3029 ensurePublicDirectoriesExist,
3130 getUserDirectoriesList,
3231 migrateSystemPrompts,
@@ -34,9 +33,10 @@ import {
3433 requireLoginMiddleware,
3534 setUserDataMiddleware,
3635 shouldRedirectToLogin,
37- tryAutoLogin,
3836 cleanUploads,
3937 getSessionCookieAge,
38+ verifySecuritySettings,
39+ loginPageMiddleware,
4040} from './src/users.js';
4141
4242import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
@@ -49,7 +49,6 @@ import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
4949import corsProxyMiddleware from './src/middleware/corsProxy.js';
5050import {
5151 getVersion,
52- getConfigValue,
5352 color,
5453 removeColorFormatting,
5554 getSeparator,
@@ -72,6 +71,11 @@ util.inspect.defaultOptions.maxArrayLength = null;
7271util.inspect.defaultOptions.maxStringLength = null;
7372util.inspect.defaultOptions.depth = 4;
7473
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+
7579// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
7680// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
7781// 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
8084 if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
8185}
8286
8387const cliParsercliArgs = new CommandLineParser().parse(process.argv);
84-const cliArgs = cliParser.parse(process.argv);
8588globalThis.DATA_ROOT = cliArgs.dataRoot;
89+globalThis.COMMAND_LINE_ARGS = cliArgs;
8690
8791if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
8892 console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
8993 process.exit(1);
9094}
9195
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+}
96107
97108const app = express();
98109app.use(helmet({
@@ -101,19 +112,6 @@ app.use(helmet({
101112app.use(compression());
102113app.use(responseTime());
103114
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-
117115// CORS Settings //
118116const CORS = cors({
119117 origin: 'null',
@@ -213,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => {
213211});
214212
215213// Host login page
216214app.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-});
234215
235216// Host frontend assets
236217const webpackMiddleware = getWebpackServeMiddleware();
@@ -289,7 +270,8 @@ async function preSetupTasks() {
289270 await settingsInit();
290271 await statsInit();
291272
292273 const cleanupPluginspluginsDirectory = await initializePluginspath.join(serverDirectory, 'plugins');
274+ const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
293275 const consoleTitle = process.title;
294276
295277 let isExiting = false;
@@ -366,80 +348,6 @@ async function postSetupTasks(result) {
366348}
367349
368350/**
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-/**
443351 * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
444352 */
445353function apply404Middleware() {
src/plugin-loader.js+37 -32
@@ -38,50 +38,55 @@ const isESModule = (file) => path.extname(file) === '.mjs';
3838 * be called before the server shuts down.
3939 */
4040export async function loadPlugins(app, pluginsPath) {
41- const exitHooks = [];
41+ try {
42- const emptyFn = () => { };
42+ const exitHooks = [];
43+ const emptyFn = () => { };
4344
4445 // Server plugins are disabled.
4546 if (!enableServerPlugins) {
4647 return emptyFn;
4748 }
4849
4950 // Plugins directory does not exist.
5051 if (!fs.existsSync(pluginsPath)) {
5152 return emptyFn;
5253 }
5354
5455 const files = fs.readdirSync(pluginsPath);
5556
5657 // No plugins to load.
5758 if (files.length === 0) {
5859 return emptyFn;
5960 }
6061
6162 await updatePlugins(pluginsPath);
6263
6364 for (const file of files) {
6465 const pluginFilePath = path.join(pluginsPath, file);
6566
6667 if (fs.statSync(pluginFilePath).isDirectory()) {
6768 await loadFromDirectory(app, pluginFilePath, exitHooks);
6869 continue;
6970 }
71+
72+ // Not a JavaScript file.
73+ if (!isCommonJS(file) && !isESModule(file)) {
74+ continue;
75+ }
7076
71- // Not a JavaScript file.
77+ await loadFromFile(app, pluginFilePath, exitHooks);
72- if (!isCommonJS(file) && !isESModule(file)) {
73- continue;
7478 }
7579
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+ }
7883
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 () => { };
8189 }
82-
83- // Call all plugin "exit" functions at once and wait for them to finish
84- return () => Promise.all(exitHooks.map(exitFn => exitFn()));
8590}
8691
8792async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {
src/users.js+91 -0
@@ -120,6 +120,72 @@ export async function ensurePublicDirectoriesExist() {
120120 return directoriesList;
121121}
122122
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+
123189export function cleanUploads() {
124190 try {
125191 const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
@@ -818,6 +884,31 @@ export function requireLoginMiddleware(request, response, next) {
818884}
819885
820886/**
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+/**
821912 * Creates a route handler for serving files from a specific directory.
822913 * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
823914 * @returns {import('express').RequestHandler}