Move security checks to users.js

This commit is contained in:
Cohee
2025-02-27 21:27:39 +02:00
parent d5056a5563
commit 60448f4ce8
4 changed files with 162 additions and 152 deletions

6
index.d.ts vendored
View File

@@ -1,4 +1,5 @@
import { UserDirectoryList, User } from "./src/users"; import { UserDirectoryList, User } from "./src/users";
import { CommandLineArguments } from "./src/command-line";
import { CsrfSyncedToken } from "csrf-sync"; import { CsrfSyncedToken } from "csrf-sync";
declare global { declare global {
@@ -32,4 +33,9 @@ declare global {
* The root directory for user data. * The root directory for user data.
*/ */
var DATA_ROOT: string; var DATA_ROOT: string;
/**
* Parsed command line arguments.
*/
var COMMAND_LINE_ARGS: CommandLineArguments;
} }

138
server.js
View File

@@ -26,7 +26,6 @@ import {
initUserStorage, initUserStorage,
getCookieSecret, getCookieSecret,
getCookieSessionName, getCookieSessionName,
getAllEnabledUsers,
ensurePublicDirectoriesExist, ensurePublicDirectoriesExist,
getUserDirectoriesList, getUserDirectoriesList,
migrateSystemPrompts, migrateSystemPrompts,
@@ -34,9 +33,10 @@ import {
requireLoginMiddleware, requireLoginMiddleware,
setUserDataMiddleware, setUserDataMiddleware,
shouldRedirectToLogin, shouldRedirectToLogin,
tryAutoLogin,
cleanUploads, cleanUploads,
getSessionCookieAge, getSessionCookieAge,
verifySecuritySettings,
loginPageMiddleware,
} from './src/users.js'; } from './src/users.js';
import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
@@ -49,7 +49,6 @@ import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
import corsProxyMiddleware from './src/middleware/corsProxy.js'; import corsProxyMiddleware from './src/middleware/corsProxy.js';
import { import {
getVersion, getVersion,
getConfigValue,
color, color,
removeColorFormatting, removeColorFormatting,
getSeparator, getSeparator,
@@ -72,6 +71,11 @@ util.inspect.defaultOptions.maxArrayLength = null;
util.inspect.defaultOptions.maxStringLength = null; util.inspect.defaultOptions.maxStringLength = null;
util.inspect.defaultOptions.depth = 4; util.inspect.defaultOptions.depth = 4;
// Set a working directory for the server
const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
process.chdir(serverDirectory);
// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
// Safe to remove once support for Node v20 is dropped. // 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
if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
} }
const cliParser = new CommandLineParser(); const cliArgs = new CommandLineParser().parse(process.argv);
const cliArgs = cliParser.parse(process.argv);
globalThis.DATA_ROOT = cliArgs.dataRoot; globalThis.DATA_ROOT = cliArgs.dataRoot;
globalThis.COMMAND_LINE_ARGS = cliArgs;
if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
process.exit(1); process.exit(1);
} }
// change all relative paths try {
const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); if (cliArgs.dnsPreferIPv6) {
console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); dns.setDefaultResultOrder('ipv6first');
process.chdir(serverDirectory); console.log('Preferring IPv6 for DNS resolution');
} else {
dns.setDefaultResultOrder('ipv4first');
console.log('Preferring IPv4 for DNS resolution');
}
} catch (error) {
console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.');
}
const app = express(); const app = express();
app.use(helmet({ app.use(helmet({
@@ -101,19 +112,6 @@ app.use(helmet({
app.use(compression()); app.use(compression());
app.use(responseTime()); app.use(responseTime());
/** @type {boolean} */
const enableAccounts = getConfigValue('enableUserAccounts', false, 'boolean');
if (cliArgs.dnsPreferIPv6) {
// Set default DNS resolution order to IPv6 first
dns.setDefaultResultOrder('ipv6first');
console.log('Preferring IPv6 for DNS resolution');
} else {
// Set default DNS resolution order to IPv4 first
dns.setDefaultResultOrder('ipv4first');
console.log('Preferring IPv4 for DNS resolution');
}
// CORS Settings // // CORS Settings //
const CORS = cors({ const CORS = cors({
origin: 'null', origin: 'null',
@@ -213,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => {
}); });
// Host login page // Host login page
app.get('/login', async (request, response) => { app.get('/login', loginPageMiddleware);
if (!enableAccounts) {
console.log('User accounts are disabled. Redirecting to index page.');
return response.redirect('/');
}
try {
const autoLogin = await tryAutoLogin(request, cliArgs.basicAuthMode);
if (autoLogin) {
return response.redirect('/');
}
} catch (error) {
console.error('Error during auto-login:', error);
}
return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
});
// Host frontend assets // Host frontend assets
const webpackMiddleware = getWebpackServeMiddleware(); const webpackMiddleware = getWebpackServeMiddleware();
@@ -289,7 +270,8 @@ async function preSetupTasks() {
await settingsInit(); await settingsInit();
await statsInit(); await statsInit();
const cleanupPlugins = await initializePlugins(); const pluginsDirectory = path.join(serverDirectory, 'plugins');
const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
const consoleTitle = process.title; const consoleTitle = process.title;
let isExiting = false; let isExiting = false;
@@ -365,80 +347,6 @@ async function postSetupTasks(result) {
setupLogLevel(); setupLogLevel();
} }
/**
* Loads server plugins from a directory.
* @returns {Promise<Function>} Function to be run on server exit
*/
async function initializePlugins() {
try {
const pluginDirectory = path.join(serverDirectory, 'plugins');
const cleanupPlugins = await loadPlugins(app, pluginDirectory);
return cleanupPlugins;
} catch {
console.log('Plugin loading failed.');
return () => { };
}
}
/**
* Prints an error message and exits the process if necessary
* @param {string} message The error message to print
* @returns {void}
*/
function logSecurityAlert(message) {
if (cliArgs.basicAuthMode || cliArgs.whitelistMode) return; // safe!
console.error(color.red(message));
if (getConfigValue('securityOverride', false, 'boolean')) {
console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
return;
}
process.exit(1);
}
async function verifySecuritySettings() {
// Skip all security checks as listen is set to false
if (!cliArgs.listen) {
return;
}
if (!enableAccounts) {
logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.');
}
const users = await getAllEnabledUsers();
const unprotectedUsers = users.filter(x => !x.password);
const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin);
if (unprotectedUsers.length > 0) {
console.warn(color.blue('A friendly reminder that the following users are not password protected:'));
unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x));
console.log();
console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`);
console.log();
if (unprotectedAdminUsers.length > 0) {
logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');
}
}
if (cliArgs.basicAuthMode) {
const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean');
if (perUserBasicAuth && !enableAccounts) {
console.error(color.red(
'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',
));
} else if (!perUserBasicAuth) {
const basicAuthUserName = getConfigValue('basicAuthUser.username', '');
const basicAuthUserPassword = getConfigValue('basicAuthUser.password', '');
if (!basicAuthUserName || !basicAuthUserPassword) {
console.warn(color.yellow(
'Basic Authentication is enabled, but username or password is not set or empty!',
));
}
}
}
}
/** /**
* Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
*/ */

View File

@@ -38,50 +38,55 @@ const isESModule = (file) => path.extname(file) === '.mjs';
* be called before the server shuts down. * be called before the server shuts down.
*/ */
export async function loadPlugins(app, pluginsPath) { export async function loadPlugins(app, pluginsPath) {
const exitHooks = []; try {
const emptyFn = () => { }; const exitHooks = [];
const emptyFn = () => { };
// Server plugins are disabled. // Server plugins are disabled.
if (!enableServerPlugins) { if (!enableServerPlugins) {
return emptyFn; return emptyFn;
}
// Plugins directory does not exist.
if (!fs.existsSync(pluginsPath)) {
return emptyFn;
}
const files = fs.readdirSync(pluginsPath);
// No plugins to load.
if (files.length === 0) {
return emptyFn;
}
await updatePlugins(pluginsPath);
for (const file of files) {
const pluginFilePath = path.join(pluginsPath, file);
if (fs.statSync(pluginFilePath).isDirectory()) {
await loadFromDirectory(app, pluginFilePath, exitHooks);
continue;
} }
// Not a JavaScript file. // Plugins directory does not exist.
if (!isCommonJS(file) && !isESModule(file)) { if (!fs.existsSync(pluginsPath)) {
continue; return emptyFn;
} }
await loadFromFile(app, pluginFilePath, exitHooks); const files = fs.readdirSync(pluginsPath);
}
if (loadedPlugins.size > 0) { // No plugins to load.
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!`); if (files.length === 0) {
} return emptyFn;
}
// Call all plugin "exit" functions at once and wait for them to finish await updatePlugins(pluginsPath);
return () => Promise.all(exitHooks.map(exitFn => exitFn()));
for (const file of files) {
const pluginFilePath = path.join(pluginsPath, file);
if (fs.statSync(pluginFilePath).isDirectory()) {
await loadFromDirectory(app, pluginFilePath, exitHooks);
continue;
}
// Not a JavaScript file.
if (!isCommonJS(file) && !isESModule(file)) {
continue;
}
await loadFromFile(app, pluginFilePath, exitHooks);
}
if (loadedPlugins.size > 0) {
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!`);
}
// Call all plugin "exit" functions at once and wait for them to finish
return () => Promise.all(exitHooks.map(exitFn => exitFn()));
} catch (error) {
console.error('Plugin loading failed.', error);
return () => { };
}
} }
async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) { async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {

View File

@@ -120,6 +120,72 @@ export async function ensurePublicDirectoriesExist() {
return directoriesList; return directoriesList;
} }
/**
* Prints an error message and exits the process if necessary
* @param {string} message The error message to print
* @returns {void}
*/
function logSecurityAlert(message) {
const { basicAuthMode, whitelistMode } = globalThis.COMMAND_LINE_ARGS;
if (basicAuthMode || whitelistMode) return; // safe!
console.error(color.red(message));
if (getConfigValue('securityOverride', false, 'boolean')) {
console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
return;
}
process.exit(1);
}
/**
* Verifies the security settings and prints warnings if necessary
* @returns {Promise<void>}
*/
export async function verifySecuritySettings() {
const { listen, basicAuthMode } = globalThis.COMMAND_LINE_ARGS;
// Skip all security checks as listen is set to false
if (!listen) {
return;
}
if (!ENABLE_ACCOUNTS) {
logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.');
}
const users = await getAllEnabledUsers();
const unprotectedUsers = users.filter(x => !x.password);
const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin);
if (unprotectedUsers.length > 0) {
console.warn(color.blue('A friendly reminder that the following users are not password protected:'));
unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x));
console.log();
console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`);
console.log();
if (unprotectedAdminUsers.length > 0) {
logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');
}
}
if (basicAuthMode) {
const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean');
if (perUserBasicAuth && !ENABLE_ACCOUNTS) {
console.error(color.red(
'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',
));
} else if (!perUserBasicAuth) {
const basicAuthUserName = getConfigValue('basicAuthUser.username', '');
const basicAuthUserPassword = getConfigValue('basicAuthUser.password', '');
if (!basicAuthUserName || !basicAuthUserPassword) {
console.warn(color.yellow(
'Basic Authentication is enabled, but username or password is not set or empty!',
));
}
}
}
}
export function cleanUploads() { export function cleanUploads() {
try { try {
const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY); const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
@@ -817,6 +883,31 @@ export function requireLoginMiddleware(request, response, next) {
return next(); return next();
} }
/**
* Middleware to host the login page.
* @param {import('express').Request} request Request object
* @param {import('express').Response} response Response object
*/
export async function loginPageMiddleware(request, response) {
if (!ENABLE_ACCOUNTS) {
console.log('User accounts are disabled. Redirecting to index page.');
return response.redirect('/');
}
try {
const { basicAuthMode } = globalThis.COMMAND_LINE_ARGS;
const autoLogin = await tryAutoLogin(request, basicAuthMode);
if (autoLogin) {
return response.redirect('/');
}
} catch (error) {
console.error('Error during auto-login:', error);
}
return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
}
/** /**
* Creates a route handler for serving files from a specific directory. * Creates a route handler for serving files from a specific directory.
* @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from