Server: Add host whitelisting (#4476) * Add host whitelisting middleware * Add prompt to enable hostWhitelist * perf: Freeze config array * Update src/middleware/hostWhitelist.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * perf: Add max known hosts limit * Add validation warning disable hint * Add conditional host whitelist middleware based on SSL configuration * Check for cache exhaustion before logging * Revert "Add conditional host whitelist middleware based on SSL configuration" This reverts commit 968104c6f4f2e4b72e1fd8ceff0a4b0ded216d69. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed| @@ -94,6 +94,18 @@ autheliaAuth: false | ||
| 94 | 94 | # the username and passwords for basic auth are the same as those |
| 95 | 95 | # for the individual accounts |
| 96 | 96 | perUserBasicAuth: false |
| 97 | +# Host whitelist configuration. Recommended if you're using a listen mode | |
| 98 | +hostWhitelist: | |
| 99 | + # Enable or disable host whitelisting | |
| 100 | + enabled: false | |
| 101 | + # Scan incoming requests for potential host header spoofing | |
| 102 | + scan: true | |
| 103 | + # List of allowed hosts. Do not include localhost or IPs, these are safe. | |
| 104 | + # Use a dot to create subdomain patterns. | |
| 105 | + # Examples: | |
| 106 | + # - example.com | |
| 107 | + # - .trycloudflare.com | |
| 108 | + hosts: [] | |
| 97 | 109 | |
| 98 | 110 | # User session timeout *in seconds* (defaults to 24 hours). |
| 99 | 111 | ## Set to a positive number to expire session after a certain time of inactivity |
| @@ -0,0 +1,21 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<html> | |
| 3 | + | |
| 4 | +<head> | |
| 5 | + <title>Forbidden</title> | |
| 6 | +</head> | |
| 7 | + | |
| 8 | +<body> | |
| 9 | + <h1>Forbidden</h1> | |
| 10 | + <p> | |
| 11 | + If you are the system administrator, add the hostname you are accessing from to the | |
| 12 | + host whitelist, or disable host whitelisting in the | |
| 13 | + <code>config.yaml</code> file located in the root directory of your installation. | |
| 14 | + </p> | |
| 15 | + <hr /> | |
| 16 | + <p> | |
| 17 | + <em>Access from this host is not allowed. This attempt has been logged.</em> | |
| 18 | + </p> | |
| 19 | +</body> | |
| 20 | + | |
| 21 | +</html> | |
| @@ -64,6 +64,7 @@ | ||
| 64 | 64 | "handlebars": "^4.7.8", |
| 65 | 65 | "helmet": "^8.1.0", |
| 66 | 66 | "highlight.js": "^11.11.1", |
| 67 | + "host-validation-middleware": "^0.1.1", | |
| 67 | 68 | "html-entities": "^2.6.0", |
| 68 | 69 | "iconv-lite": "^0.6.3", |
| 69 | 70 | "ip-matching": "^2.1.2", |
| @@ -5249,6 +5250,15 @@ | ||
| 5249 | 5250 | "node": ">=12.0.0" |
| 5250 | 5251 | } |
| 5251 | 5252 | }, |
| 5253 | + "node_modules/host-validation-middleware": { | |
| 5254 | + "version": "0.1.1", | |
| 5255 | + "resolved": "https://registry.npmjs.org/host-validation-middleware/-/host-validation-middleware-0.1.1.tgz", | |
| 5256 | + "integrity": "sha512-fakcpp+x4nbP0fACY5gaHWpaOfstq3w8uB6wvhbPBLqH9GV/tdiM9Ht5mclZVbUuPLGBw1bkH5yyTD6HZq057g==", | |
| 5257 | + "license": "MIT", | |
| 5258 | + "engines": { | |
| 5259 | + "node": "^18.0.0 || >=20.0.0" | |
| 5260 | + } | |
| 5261 | + }, | |
| 5252 | 5262 | "node_modules/html-entities": { |
| 5253 | 5263 | "version": "2.6.0", |
| 5254 | 5264 | "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", |
| @@ -54,6 +54,7 @@ | ||
| 54 | 54 | "handlebars": "^4.7.8", |
| 55 | 55 | "helmet": "^8.1.0", |
| 56 | 56 | "highlight.js": "^11.11.1", |
| 57 | + "host-validation-middleware": "^0.1.1", | |
| 57 | 58 | "html-entities": "^2.6.0", |
| 58 | 59 | "iconv-lite": "^0.6.3", |
| 59 | 60 | "ip-matching": "^2.1.2", |
| @@ -0,0 +1,48 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import { color, getConfigValue, safeReadFileSync } from '../util.js'; | |
| 3 | +import { serverDirectory } from '../server-directory.js'; | |
| 4 | +import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware'; | |
| 5 | + | |
| 6 | +const knownHosts = new Set(); | |
| 7 | +const maxKnownHosts = 1000; | |
| 8 | + | |
| 9 | +const hostWhitelistEnabled = !!getConfigValue('hostWhitelist.enabled', false); | |
| 10 | +const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', [])); | |
| 11 | +const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean'); | |
| 12 | + | |
| 13 | +const hostNotAllowedHtml = safeReadFileSync(path.join(serverDirectory, 'public/error/host-not-allowed.html'))?.toString() ?? ''; | |
| 14 | + | |
| 15 | +const validationMiddleware = hostValidationMiddleware({ | |
| 16 | + allowedHosts: hostWhitelist, | |
| 17 | + generateErrorMessage: () => hostNotAllowedHtml, | |
| 18 | + errorResponseContentType: 'text/html', | |
| 19 | +}); | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Middleware to validate remote hosts. | |
| 23 | + * Useful to protect against DNS rebinding attacks. | |
| 24 | + * @param {import('express').Request} req Request | |
| 25 | + * @param {import('express').Response} res Response | |
| 26 | + * @param {import('express').NextFunction} next Next middleware | |
| 27 | + */ | |
| 28 | +export default function hostWhitelistMiddleware(req, res, next) { | |
| 29 | + const hostValue = req.headers.host; | |
| 30 | + if (hostWhitelistScan && !isHostAllowed(hostValue, hostWhitelist) && !knownHosts.has(hostValue) && knownHosts.size < maxKnownHosts) { | |
| 31 | + const isFirstWarning = knownHosts.size === 0; | |
| 32 | + console.warn(color.red('Request from untrusted host:'), hostValue); | |
| 33 | + console.warn(`If you trust this host, you can add it to ${color.yellow('hostWhitelist.hosts')} in config.yaml`); | |
| 34 | + if (!hostWhitelistEnabled && isFirstWarning) { | |
| 35 | + console.warn(`To protect against host spoofing, consider setting ${color.yellow('hostWhitelist.enabled')} to true`); | |
| 36 | + } | |
| 37 | + if (isFirstWarning) { | |
| 38 | + console.warn(`To disable this warning, set ${color.yellow('hostWhitelist.scan')} to false`); | |
| 39 | + } | |
| 40 | + knownHosts.add(hostValue); | |
| 41 | + } | |
| 42 | + | |
| 43 | + if (!hostWhitelistEnabled) { | |
| 44 | + return next(); | |
| 45 | + } | |
| 46 | + | |
| 47 | + return validationMiddleware(req, res, next); | |
| 48 | +} | |
| @@ -46,6 +46,7 @@ import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; | ||
| 46 | 46 | import initRequestProxy from './request-proxy.js'; |
| 47 | 47 | import cacheBuster from './middleware/cacheBuster.js'; |
| 48 | 48 | import corsProxyMiddleware from './middleware/corsProxy.js'; |
| 49 | +import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; | |
| 49 | 50 | import { |
| 50 | 51 | getVersion, |
| 51 | 52 | color, |
| @@ -116,6 +117,8 @@ if (cliArgs.whitelistMode) { | ||
| 116 | 117 | app.use(whitelistMiddleware); |
| 117 | 118 | } |
| 118 | 119 | |
| 120 | +app.use(hostWhitelistMiddleware); | |
| 121 | + | |
| 119 | 122 | if (cliArgs.listen) { |
| 120 | 123 | app.use(accessLoggerMiddleware()); |
| 121 | 124 | } |