Move security checks to users.js

60448f4ce876245e8eb30efba721b27cba856cfa

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

4 files changed, +125 -115Showing whitespace changes
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,39 +84,33 @@ 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));
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-
10797 if (cliArgs.dnsPreferIPv6) {
108- // Set default DNS resolution order to IPv6 first
10998 dns.setDefaultResultOrder('ipv6first');
11099 console.log('Preferring IPv6 for DNS resolution');
111100 } else {
112- // Set default DNS resolution order to IPv4 first
113101 dns.setDefaultResultOrder('ipv4first');
114102 console.log('Preferring IPv4 for DNS resolution');
115103 }
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());
116114
117115// CORS Settings //
118116const CORS = cors({
@@ -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+5 -0
@@ -38,6 +38,7 @@ const isESModule = (file) => path.extname(file) === '.mjs';
3838 * be called before the server shuts down.
3939 */
4040export async function loadPlugins(app, pluginsPath) {
41+ try {
4142 const exitHooks = [];
4243 const emptyFn = () => { };
4344
@@ -82,6 +83,10 @@ export async function loadPlugins(app, pluginsPath) {
8283
8384 // Call all plugin "exit" functions at once and wait for them to finish
8485 return () => Promise.all(exitHooks.map(exitFn => exitFn()));
86+ } catch (error) {
87+ console.error('Plugin loading failed.', error);
88+ return () => { };
89+ }
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}