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 @@
1import { UserDirectoryList, User } from "./src/users";1import { UserDirectoryList, User } from "./src/users";
2import { CommandLineArguments } from "./src/command-line";
2import { CsrfSyncedToken } from "csrf-sync";3import { CsrfSyncedToken } from "csrf-sync";
34
4declare global {5declare 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}
server.js+23 -115
@@ -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';
4141
42import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';42import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
@@ -49,7 +49,6 @@ import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
49import corsProxyMiddleware from './src/middleware/corsProxy.js';49import corsProxyMiddleware from './src/middleware/corsProxy.js';
50import {50import {
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;
72util.inspect.defaultOptions.maxStringLength = null;71util.inspect.defaultOptions.maxStringLength = null;
73util.inspect.defaultOptions.depth = 4;72util.inspect.defaultOptions.depth = 4;
7473
74// Set a working directory for the server
75const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
76console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
77process.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-156470887080// 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}
8286
83const cliParser = new CommandLineParser();87const cliArgs = new CommandLineParser().parse(process.argv);
84const cliArgs = cliParser.parse(process.argv);
85globalThis.DATA_ROOT = cliArgs.dataRoot;88globalThis.DATA_ROOT = cliArgs.dataRoot;
89globalThis.COMMAND_LINE_ARGS = cliArgs;
8690
87if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {91if (!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}
9195
92// change all relative paths96try {
93const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));97 if (cliArgs.dnsPreferIPv6) {
94console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);98 dns.setDefaultResultOrder('ipv6first');
95process.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
97const app = express();108const app = express();
98app.use(helmet({109app.use(helmet({
@@ -101,19 +112,6 @@ app.use(helmet({
101app.use(compression());112app.use(compression());
102app.use(responseTime());113app.use(responseTime());
103114
104/** @type {boolean} */
105const enableAccounts = getConfigValue('enableUserAccounts', false, 'boolean');
106
107if (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 //
118const CORS = cors({116const CORS = cors({
119 origin: 'null',117 origin: 'null',
@@ -213,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => {
213});211});
214212
215// Host login page213// Host login page
216app.get('/login', async (request, response) => {214app.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});
234215
235// Host frontend assets216// Host frontend assets
236const webpackMiddleware = getWebpackServeMiddleware();217const webpackMiddleware = getWebpackServeMiddleware();
@@ -289,7 +270,8 @@ async function preSetupTasks() {
289 await settingsInit();270 await settingsInit();
290 await statsInit();271 await statsInit();
291272
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;
294276
295 let isExiting = false;277 let isExiting = false;
@@ -366,80 +348,6 @@ async function postSetupTasks(result) {
366}348}
367349
368/**350/**
369 * Loads server plugins from a directory.
370 * @returns {Promise<Function>} Function to be run on server exit
371 */
372async 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 */
388function 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
398async 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 */
445function apply404Middleware() {353function apply404Middleware() {
src/plugin-loader.js+37 -32
@@ -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 */
40export async function loadPlugins(app, pluginsPath) {40export async function loadPlugins(app, pluginsPath) {
41 const exitHooks = [];41 try {
42 const emptyFn = () => { };42 const exitHooks = [];
43 const emptyFn = () => { };
4344
44 // Server plugins are disabled.45 // Server plugins are disabled.
45 if (!enableServerPlugins) {46 if (!enableServerPlugins) {
46 return emptyFn;47 return emptyFn;
47 }48 }
4849
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 }
5354
54 const files = fs.readdirSync(pluginsPath);55 const files = fs.readdirSync(pluginsPath);
5556
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 }
6061
61 await updatePlugins(pluginsPath);62 await updatePlugins(pluginsPath);
6263
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);
6566
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 }
7076
71 // Not a JavaScript file.77 await loadFromFile(app, pluginFilePath, exitHooks);
72 if (!isCommonJS(file) && !isESModule(file)) {
73 continue;
74 }78 }
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 () => { };
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}
8691
87async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {92async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {
src/users.js+91 -0
@@ -120,6 +120,72 @@ export async function ensurePublicDirectoriesExist() {
120 return directoriesList;120 return directoriesList;
121}121}
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 */
128function 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 */
143export 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
123export function cleanUploads() {189export 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}
819885
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 */
891export 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 from913 * @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}