diff --git a/default/config.yaml b/default/config.yaml index b9881e323..500447390 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -94,6 +94,18 @@ autheliaAuth: false # the username and passwords for basic auth are the same as those # for the individual accounts perUserBasicAuth: false +# Host whitelist configuration. Recommended if you're using a listen mode +hostWhitelist: + # Enable or disable host whitelisting + enabled: false + # Scan incoming requests for potential host header spoofing + scan: true + # List of allowed hosts. Do not include localhost or IPs, these are safe. + # Use a dot to create subdomain patterns. + # Examples: + # - example.com + # - .trycloudflare.com + hosts: [] # User session timeout *in seconds* (defaults to 24 hours). ## Set to a positive number to expire session after a certain time of inactivity diff --git a/default/public/error/host-not-allowed.html b/default/public/error/host-not-allowed.html new file mode 100644 index 000000000..19ec5e990 --- /dev/null +++ b/default/public/error/host-not-allowed.html @@ -0,0 +1,21 @@ + + + + + Forbidden + + + +

Forbidden

+

+ If you are the system administrator, add the hostname you are accessing from to the + host whitelist, or disable host whitelisting in the + config.yaml file located in the root directory of your installation. +

+
+

+ Access from this host is not allowed. This attempt has been logged. +

+ + + diff --git a/package-lock.json b/package-lock.json index ddddeda79..6012000c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "handlebars": "^4.7.8", "helmet": "^8.1.0", "highlight.js": "^11.11.1", + "host-validation-middleware": "^0.1.1", "html-entities": "^2.6.0", "iconv-lite": "^0.6.3", "ip-matching": "^2.1.2", @@ -5249,6 +5250,15 @@ "node": ">=12.0.0" } }, + "node_modules/host-validation-middleware": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/host-validation-middleware/-/host-validation-middleware-0.1.1.tgz", + "integrity": "sha512-fakcpp+x4nbP0fACY5gaHWpaOfstq3w8uB6wvhbPBLqH9GV/tdiM9Ht5mclZVbUuPLGBw1bkH5yyTD6HZq057g==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", diff --git a/package.json b/package.json index 18ecd283c..1b3fa682c 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "handlebars": "^4.7.8", "helmet": "^8.1.0", "highlight.js": "^11.11.1", + "host-validation-middleware": "^0.1.1", "html-entities": "^2.6.0", "iconv-lite": "^0.6.3", "ip-matching": "^2.1.2", diff --git a/src/middleware/hostWhitelist.js b/src/middleware/hostWhitelist.js new file mode 100644 index 000000000..02950396c --- /dev/null +++ b/src/middleware/hostWhitelist.js @@ -0,0 +1,48 @@ +import path from 'node:path'; +import { color, getConfigValue, safeReadFileSync } from '../util.js'; +import { serverDirectory } from '../server-directory.js'; +import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware'; + +const knownHosts = new Set(); +const maxKnownHosts = 1000; + +const hostWhitelistEnabled = !!getConfigValue('hostWhitelist.enabled', false); +const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', [])); +const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean'); + +const hostNotAllowedHtml = safeReadFileSync(path.join(serverDirectory, 'public/error/host-not-allowed.html'))?.toString() ?? ''; + +const validationMiddleware = hostValidationMiddleware({ + allowedHosts: hostWhitelist, + generateErrorMessage: () => hostNotAllowedHtml, + errorResponseContentType: 'text/html', +}); + +/** + * Middleware to validate remote hosts. + * Useful to protect against DNS rebinding attacks. + * @param {import('express').Request} req Request + * @param {import('express').Response} res Response + * @param {import('express').NextFunction} next Next middleware + */ +export default function hostWhitelistMiddleware(req, res, next) { + const hostValue = req.headers.host; + if (hostWhitelistScan && !isHostAllowed(hostValue, hostWhitelist) && !knownHosts.has(hostValue) && knownHosts.size < maxKnownHosts) { + const isFirstWarning = knownHosts.size === 0; + console.warn(color.red('Request from untrusted host:'), hostValue); + console.warn(`If you trust this host, you can add it to ${color.yellow('hostWhitelist.hosts')} in config.yaml`); + if (!hostWhitelistEnabled && isFirstWarning) { + console.warn(`To protect against host spoofing, consider setting ${color.yellow('hostWhitelist.enabled')} to true`); + } + if (isFirstWarning) { + console.warn(`To disable this warning, set ${color.yellow('hostWhitelist.scan')} to false`); + } + knownHosts.add(hostValue); + } + + if (!hostWhitelistEnabled) { + return next(); + } + + return validationMiddleware(req, res, next); +} diff --git a/src/server-main.js b/src/server-main.js index 13e9ca560..b440727c0 100644 --- a/src/server-main.js +++ b/src/server-main.js @@ -46,6 +46,7 @@ import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; import initRequestProxy from './request-proxy.js'; import cacheBuster from './middleware/cacheBuster.js'; import corsProxyMiddleware from './middleware/corsProxy.js'; +import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; import { getVersion, color, @@ -116,6 +117,8 @@ if (cliArgs.whitelistMode) { app.use(whitelistMiddleware); } +app.use(hostWhitelistMiddleware); + if (cliArgs.listen) { app.use(accessLoggerMiddleware()); }