From 90459116e36997ab75384af55b5c224520745173 Mon Sep 17 00:00:00 2001 From: Spappz <34202141+Spappz@users.noreply.github.com> Date: Fri, 24 Jan 2025 03:35:56 +0000 Subject: [PATCH 1/8] woohoo --- default/config.yaml | 2 ++ src/middleware/whitelist.js | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/default/config.yaml b/default/config.yaml index 4c28044dc..226aa7dad 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -31,6 +31,8 @@ enableForwardedWhitelist: true whitelist: - ::1 - 127.0.0.1 +# HTML displayed when a connection is blocked. Use "{{ipDetails}}" to print the client's IP. +whitelistErrorMessage: "

Forbidden

If you are the system administrator, add your IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your installation.


Connection from {{ipDetails}} has been blocked. This attempt has been logged.

" # Toggle basic authentication for endpoints basicAuthMode: false # Basic authentication credentials diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index 9864e19ae..81f3e0b4f 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -1,6 +1,7 @@ import path from 'node:path'; import fs from 'node:fs'; import process from 'node:process'; +import Handlebars from 'handlebars'; import ipMatching from 'ip-matching'; import { getIpFromRequest } from '../express-common.js'; @@ -11,6 +12,9 @@ const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', fals let whitelist = getConfigValue('whitelist', []); let knownIPs = new Set(); +const DEFAULT_WHITELIST_ERROR_MESSAGE = + '

Forbidden

If you are the system administrator, add your IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your installation.


Connection from {{ipDetails}} has been blocked. This attempt has been logged.

'; + if (fs.existsSync(whitelistPath)) { try { let whitelistTxt = fs.readFileSync(whitelistPath, 'utf-8'); @@ -55,9 +59,9 @@ export default function whitelistMiddleware(whitelistMode, listen) { return function (req, res, next) { const clientIp = getIpFromRequest(req); const forwardedIp = getForwardedIp(req); + const userAgent = req.headers['user-agent']; if (listen && !knownIPs.has(clientIp)) { - const userAgent = req.headers['user-agent']; console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); knownIPs.add(clientIp); @@ -76,9 +80,21 @@ export default function whitelistMiddleware(whitelistMode, listen) { || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) ) { // Log the connection attempt with real IP address - const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp; - console.log(color.red('Forbidden: Connection attempt from ' + ipDetails + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.\n')); - return res.status(403).send('Forbidden: Connection attempt from ' + ipDetails + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.'); + const ipDetails = forwardedIp + ? `${clientIp} (forwarded from ${forwardedIp})` + : clientIp; + const errorMessage = Handlebars.compile( + getConfigValue( + 'whitelistErrorMessage', + DEFAULT_WHITELIST_ERROR_MESSAGE, + ), + ); + console.log( + color.red( + `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`, + ), + ); + return res.status(403).send(errorMessage({ ipDetails })); } next(); }; From 075368b5aec0cdb1d04c372f15bf1301b7ad2e57 Mon Sep 17 00:00:00 2001 From: Spappz <34202141+Spappz@users.noreply.github.com> Date: Fri, 24 Jan 2025 19:56:19 +0000 Subject: [PATCH 2/8] Ensure Handlebars template is only compiled once --- src/middleware/whitelist.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index 81f3e0b4f..200a359ce 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -15,6 +15,13 @@ let knownIPs = new Set(); const DEFAULT_WHITELIST_ERROR_MESSAGE = '

Forbidden

If you are the system administrator, add your IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your installation.


Connection from {{ipDetails}} has been blocked. This attempt has been logged.

'; +const errorMessage = Handlebars.compile( + getConfigValue( + 'whitelistErrorMessage', + DEFAULT_WHITELIST_ERROR_MESSAGE, + ), +); + if (fs.existsSync(whitelistPath)) { try { let whitelistTxt = fs.readFileSync(whitelistPath, 'utf-8'); @@ -83,12 +90,6 @@ export default function whitelistMiddleware(whitelistMode, listen) { const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp; - const errorMessage = Handlebars.compile( - getConfigValue( - 'whitelistErrorMessage', - DEFAULT_WHITELIST_ERROR_MESSAGE, - ), - ); console.log( color.red( `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`, From 928487985d56043a1db3bb54d41657db2966c1b3 Mon Sep 17 00:00:00 2001 From: Spappz <34202141+Spappz@users.noreply.github.com> Date: Sat, 25 Jan 2025 03:38:52 +0000 Subject: [PATCH 3/8] defer 403 HTML to file --- default/config.yaml | 2 -- .../public/error/forbidden-by-whitelist.html | 22 +++++++++++++++++++ src/middleware/whitelist.js | 13 ++--------- 3 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 default/public/error/forbidden-by-whitelist.html diff --git a/default/config.yaml b/default/config.yaml index 226aa7dad..4c28044dc 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -31,8 +31,6 @@ enableForwardedWhitelist: true whitelist: - ::1 - 127.0.0.1 -# HTML displayed when a connection is blocked. Use "{{ipDetails}}" to print the client's IP. -whitelistErrorMessage: "

Forbidden

If you are the system administrator, add your IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your installation.


Connection from {{ipDetails}} has been blocked. This attempt has been logged.

" # Toggle basic authentication for endpoints basicAuthMode: false # Basic authentication credentials diff --git a/default/public/error/forbidden-by-whitelist.html b/default/public/error/forbidden-by-whitelist.html new file mode 100644 index 000000000..70ff71852 --- /dev/null +++ b/default/public/error/forbidden-by-whitelist.html @@ -0,0 +1,22 @@ + + + + + Forbidden + + + +

Forbidden

+

+ If you are the system administrator, add your IP address to the + whitelist or disable whitelist mode by editing + config.yaml in the root directory of your installation. +

+
+

+ Connection from {{ipDetails}} has been blocked. This attempt + has been logged. +

+ + + diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index 200a359ce..6edf5a75a 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -11,16 +11,7 @@ const whitelistPath = path.join(process.cwd(), './whitelist.txt'); const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); let whitelist = getConfigValue('whitelist', []); let knownIPs = new Set(); - -const DEFAULT_WHITELIST_ERROR_MESSAGE = - '

Forbidden

If you are the system administrator, add your IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your installation.


Connection from {{ipDetails}} has been blocked. This attempt has been logged.

'; - -const errorMessage = Handlebars.compile( - getConfigValue( - 'whitelistErrorMessage', - DEFAULT_WHITELIST_ERROR_MESSAGE, - ), -); +const forbiddenWebpage = Handlebars.compile(fs.readFileSync('./public/error/forbidden-by-whitelist.html', 'utf-8')); if (fs.existsSync(whitelistPath)) { try { @@ -95,7 +86,7 @@ export default function whitelistMiddleware(whitelistMode, listen) { `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`, ), ); - return res.status(403).send(errorMessage({ ipDetails })); + return res.status(403).send(forbiddenWebpage({ ipDetails })); } next(); }; From 538d66191e9558b4424d07fbf66aac40185f1c98 Mon Sep 17 00:00:00 2001 From: Spappz <34202141+Spappz@users.noreply.github.com> Date: Sat, 25 Jan 2025 03:40:47 +0000 Subject: [PATCH 4/8] add 401 error page for `basicAuth` mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most modern browsers don't actually show users 401 responses, but it doesn't hurt to have it in there anyway ¯\_(ツ)_/¯ --- default/public/error/unauthorized.html | 17 +++++++++++++++++ src/middleware/basicAuth.js | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 default/public/error/unauthorized.html diff --git a/default/public/error/unauthorized.html b/default/public/error/unauthorized.html new file mode 100644 index 000000000..e3fa5f94d --- /dev/null +++ b/default/public/error/unauthorized.html @@ -0,0 +1,17 @@ + + + + + Unauthorized + + + +

Unauthorized

+

+ If you are the system administrator, you can configure the + basicAuthUser credentials by editing + config.yaml in the root directory of your installation. +

+ + + diff --git a/src/middleware/basicAuth.js b/src/middleware/basicAuth.js index 87b7fbcf8..542aad634 100644 --- a/src/middleware/basicAuth.js +++ b/src/middleware/basicAuth.js @@ -6,13 +6,15 @@ import { Buffer } from 'node:buffer'; import storage from 'node-persist'; import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; import { getConfig, getConfigValue } from '../util.js'; +import { readFileSync } from 'node:fs'; const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); +const unauthorizedWebpage = readFileSync('./public/error/unauthorized.html', { encoding: 'utf-8' }); const unauthorizedResponse = (res) => { res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); - return res.status(401).send('Authentication required'); + return res.status(401).send(unauthorizedWebpage); }; const basicAuthMiddleware = async function (request, response, callback) { From a5dc505e613615a905f7f39e921f9d274b87fb36 Mon Sep 17 00:00:00 2001 From: Spappz <34202141+Spappz@users.noreply.github.com> Date: Sat, 25 Jan 2025 03:42:04 +0000 Subject: [PATCH 5/8] add 404 error-handling to server This is all that seems necessary according to Express? Admittedly my first time using it. https://expressjs.com/en/starter/faq.html#how-do-i-handle-404-responses --- default/public/error/url-not-found.html | 15 +++++++++++++++ server.js | 6 ++++++ 2 files changed, 21 insertions(+) create mode 100644 default/public/error/url-not-found.html diff --git a/default/public/error/url-not-found.html b/default/public/error/url-not-found.html new file mode 100644 index 000000000..87974145f --- /dev/null +++ b/default/public/error/url-not-found.html @@ -0,0 +1,15 @@ + + + + + Not found + + + +

Not found

+

+ The requested URL was not found on this server. +

+ + + diff --git a/server.js b/server.js index db7ba9306..bc2a884c2 100644 --- a/server.js +++ b/server.js @@ -615,6 +615,12 @@ app.use('/api/backends/scale-alt', scaleAltRouter); app.use('/api/speech', speechRouter); app.use('/api/azure', azureRouter); +// If all other middlewares fail, send 404 error. +const notFoundWebpage = fs.readFileSync('./public/error/url-not-found.html', { encoding: 'utf-8' }); +app.use((req, res, next) => { + res.status(404).send(notFoundWebpage); +}); + const tavernUrlV6 = new URL( (cliArguments.ssl ? 'https://' : 'http://') + (listen ? '[::]' : '[::1]') + From e07faea874bf201e65aa442a5136dcd27ef8e414 Mon Sep 17 00:00:00 2001 From: Spappz <34202141+Spappz@users.noreply.github.com> Date: Sat, 25 Jan 2025 03:45:16 +0000 Subject: [PATCH 6/8] rework `createDefaultFiles()` Reorganised copy-able `default/` files as a sparse copy of the production file-tree. This should save the `defaultItems` (formerly `files`) array from getting unwieldy. --- .gitignore | 1 + default/{ => public/css}/user.css | 0 post-install.js | 62 +++++++++++++++++++++++++------ 3 files changed, 52 insertions(+), 11 deletions(-) rename default/{ => public/css}/user.css (100%) diff --git a/.gitignore b/.gitignore index 7d48fd879..de09f68ee 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ access.log /vectors/ /cache/ public/css/user.css +public/error/ /plugins/ /data /default/scaffold diff --git a/default/user.css b/default/public/css/user.css similarity index 100% rename from default/user.css rename to default/public/css/user.css diff --git a/post-install.js b/post-install.js index 4c9765eb8..f7863ab66 100644 --- a/post-install.js +++ b/post-install.js @@ -2,7 +2,7 @@ * Scripts to be done before starting the server for the first time. */ import fs from 'node:fs'; -import path from 'node:path'; +import path from 'node:path/posix'; // Windows can handle Unix paths, but not vice-versa import crypto from 'node:crypto'; import process from 'node:process'; import yaml from 'yaml'; @@ -213,20 +213,60 @@ function addMissingConfigValues() { * Creates the default config files if they don't exist yet. */ function createDefaultFiles() { - const files = { - config: './config.yaml', - user: './public/css/user.css', - }; + /** + * @typedef DefaultItem + * @type {object} + * @property {'file' | 'directory'} type - Whether the item should be copied as a single file or merged into a directory structure. + * @property {string} defaultPath - The path to the default item (typically in `default/`). + * @property {string} productionPath - The path to the copied item for production use. + */ - for (const file of Object.values(files)) { + /** @type {DefaultItem[]} */ + const defaultItems = [ + { + type: 'file', + defaultPath: './default/config.yaml', + productionPath: './config.yaml', + }, + { + type: 'directory', + defaultPath: './default/public/', + productionPath: './public/', + }, + ]; + + for (const defaultItem of defaultItems) { try { - if (!fs.existsSync(file)) { - const defaultFilePath = path.join('./default', path.parse(file).base); - fs.copyFileSync(defaultFilePath, file); - console.log(color.green(`Created default file: ${file}`)); + if (defaultItem.type === 'file') { + if (!fs.existsSync(defaultItem.productionPath)) { + fs.copyFileSync( + defaultItem.defaultPath, + defaultItem.productionPath, + ); + console.log( + color.green(`Created default file: ${defaultItem.productionPath}`), + ); + } + } else if (defaultItem.type === 'directory') { + fs.cpSync(defaultItem.defaultPath, defaultItem.productionPath, { + force: false, // Don't overwrite existing files! + recursive: true, + }); + console.log( + color.green(`Synchronized missing files: ${defaultItem.productionPath}`), + ); + } else { + throw new Error( + 'FATAL: Unexpected default file format in `post-install.js#createDefaultFiles()`.', + ); } } catch (error) { - console.error(color.red(`FATAL: Could not write default file: ${file}`), error); + console.error( + color.red( + `FATAL: Could not write default ${defaultItem.type}: ${defaultItem.productionPath}`, + ), + error, + ); } } } From 9e54070c1d6176fe4fa2e21e523ba14507d0349f Mon Sep 17 00:00:00 2001 From: Spappz <34202141+Spappz@users.noreply.github.com> Date: Sat, 25 Jan 2025 19:38:01 +0000 Subject: [PATCH 7/8] Revert `path/posix` to `path` in `post-install.js` --- post-install.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/post-install.js b/post-install.js index f7863ab66..46ad160cb 100644 --- a/post-install.js +++ b/post-install.js @@ -2,7 +2,7 @@ * Scripts to be done before starting the server for the first time. */ import fs from 'node:fs'; -import path from 'node:path/posix'; // Windows can handle Unix paths, but not vice-versa +import path from 'node:path'; import crypto from 'node:crypto'; import process from 'node:process'; import yaml from 'yaml'; From 6099ffece134bcde9197c1be51a0b23f885465b5 Mon Sep 17 00:00:00 2001 From: Spappz <34202141+Spappz@users.noreply.github.com> Date: Sat, 25 Jan 2025 20:29:31 +0000 Subject: [PATCH 8/8] No exceptions on missing error webpages - Create a `safeReadFileSync()` function in `src/utils.js` to wrap around `fs.readFileSync()` - Migrate error-webpage loads to use `safeReadFileSync()`, with default values of an empty string - Move the 404 error middleware to explicitly only be called *after* extensions are registered --- server.js | 18 ++++++++++++------ src/middleware/basicAuth.js | 5 ++--- src/middleware/whitelist.js | 6 ++++-- src/util.js | 11 +++++++++++ 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/server.js b/server.js index bc2a884c2..cbd161847 100644 --- a/server.js +++ b/server.js @@ -67,6 +67,7 @@ import { forwardFetchResponse, removeColorFormatting, getSeparator, + safeReadFileSync, } from './src/util.js'; import { UPLOADS_DIRECTORY } from './src/constants.js'; import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; @@ -615,12 +616,6 @@ app.use('/api/backends/scale-alt', scaleAltRouter); app.use('/api/speech', speechRouter); app.use('/api/azure', azureRouter); -// If all other middlewares fail, send 404 error. -const notFoundWebpage = fs.readFileSync('./public/error/url-not-found.html', { encoding: 'utf-8' }); -app.use((req, res, next) => { - res.status(404).send(notFoundWebpage); -}); - const tavernUrlV6 = new URL( (cliArguments.ssl ? 'https://' : 'http://') + (listen ? '[::]' : '[::1]') + @@ -927,6 +922,16 @@ async function verifySecuritySettings() { } } +/** + * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. + */ +function apply404Middleware() { + const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? ''; + app.use((req, res) => { + res.status(404).send(notFoundWebpage); + }); +} + // User storage module needs to be initialized before starting the server initUserStorage(dataRoot) .then(ensurePublicDirectoriesExist) @@ -934,4 +939,5 @@ initUserStorage(dataRoot) .then(migrateSystemPrompts) .then(verifySecuritySettings) .then(preSetupTasks) + .then(apply404Middleware) .finally(startServer); diff --git a/src/middleware/basicAuth.js b/src/middleware/basicAuth.js index 542aad634..4548a3f20 100644 --- a/src/middleware/basicAuth.js +++ b/src/middleware/basicAuth.js @@ -5,13 +5,12 @@ import { Buffer } from 'node:buffer'; import storage from 'node-persist'; import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; -import { getConfig, getConfigValue } from '../util.js'; -import { readFileSync } from 'node:fs'; +import { getConfig, getConfigValue, safeReadFileSync } from '../util.js'; const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); -const unauthorizedWebpage = readFileSync('./public/error/unauthorized.html', { encoding: 'utf-8' }); +const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? ''; const unauthorizedResponse = (res) => { res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); return res.status(401).send(unauthorizedWebpage); diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index 6edf5a75a..2922c42b1 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -5,13 +5,15 @@ import Handlebars from 'handlebars'; import ipMatching from 'ip-matching'; import { getIpFromRequest } from '../express-common.js'; -import { color, getConfigValue } from '../util.js'; +import { color, getConfigValue, safeReadFileSync } from '../util.js'; const whitelistPath = path.join(process.cwd(), './whitelist.txt'); const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); let whitelist = getConfigValue('whitelist', []); let knownIPs = new Set(); -const forbiddenWebpage = Handlebars.compile(fs.readFileSync('./public/error/forbidden-by-whitelist.html', 'utf-8')); +const forbiddenWebpage = Handlebars.compile( + safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', +); if (fs.existsSync(whitelistPath)) { try { diff --git a/src/util.js b/src/util.js index 0fe03e76c..19bd3ccc7 100644 --- a/src/util.js +++ b/src/util.js @@ -871,3 +871,14 @@ export class MemoryLimitedMap { return this.map[Symbol.iterator](); } } + +/** + * A 'safe' version of `fs.readFileSync()`. Returns the contents of a file if it exists, falling back to a default value if not. + * @param {string} filePath Path of the file to be read. + * @param {Parameters[1]} options Options object to pass through to `fs.readFileSync()` (default: `{ encoding: 'utf-8' }`). + * @returns The contents at `filePath` if it exists, or `null` if not. + */ +export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { + if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); + return null; +}