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 | # the username and passwords for basic auth are the same as those | 94 | # the username and passwords for basic auth are the same as those |
| 95 | # for the individual accounts | 95 | # for the individual accounts |
| 96 | perUserBasicAuth: false | 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 | # User session timeout *in seconds* (defaults to 24 hours). | 110 | # User session timeout *in seconds* (defaults to 24 hours). |
| 99 | ## Set to a positive number to expire session after a certain time of inactivity | 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 | "handlebars": "^4.7.8", | 64 | "handlebars": "^4.7.8", |
| 65 | "helmet": "^8.1.0", | 65 | "helmet": "^8.1.0", |
| 66 | "highlight.js": "^11.11.1", | 66 | "highlight.js": "^11.11.1", |
| 67 | "host-validation-middleware": "^0.1.1", | ||
| 67 | "html-entities": "^2.6.0", | 68 | "html-entities": "^2.6.0", |
| 68 | "iconv-lite": "^0.6.3", | 69 | "iconv-lite": "^0.6.3", |
| 69 | "ip-matching": "^2.1.2", | 70 | "ip-matching": "^2.1.2", |
| @@ -5249,6 +5250,15 @@ | |||
| 5249 | "node": ">=12.0.0" | 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 | "node_modules/html-entities": { | 5262 | "node_modules/html-entities": { |
| 5253 | "version": "2.6.0", | 5263 | "version": "2.6.0", |
| 5254 | "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", | 5264 | "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", |
| @@ -54,6 +54,7 @@ | |||
| 54 | "handlebars": "^4.7.8", | 54 | "handlebars": "^4.7.8", |
| 55 | "helmet": "^8.1.0", | 55 | "helmet": "^8.1.0", |
| 56 | "highlight.js": "^11.11.1", | 56 | "highlight.js": "^11.11.1", |
| 57 | "host-validation-middleware": "^0.1.1", | ||
| 57 | "html-entities": "^2.6.0", | 58 | "html-entities": "^2.6.0", |
| 58 | "iconv-lite": "^0.6.3", | 59 | "iconv-lite": "^0.6.3", |
| 59 | "ip-matching": "^2.1.2", | 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 | import initRequestProxy from './request-proxy.js'; | 46 | import initRequestProxy from './request-proxy.js'; |
| 47 | import cacheBuster from './middleware/cacheBuster.js'; | 47 | import cacheBuster from './middleware/cacheBuster.js'; |
| 48 | import corsProxyMiddleware from './middleware/corsProxy.js'; | 48 | import corsProxyMiddleware from './middleware/corsProxy.js'; |
| 49 | import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; | ||
| 49 | import { | 50 | import { |
| 50 | getVersion, | 51 | getVersion, |
| 51 | color, | 52 | color, |
| @@ -116,6 +117,8 @@ if (cliArgs.whitelistMode) { | |||
| 116 | app.use(whitelistMiddleware); | 117 | app.use(whitelistMiddleware); |
| 117 | } | 118 | } |
| 118 | 119 | ||
| 120 | app.use(hostWhitelistMiddleware); | ||
| 121 | |||
| 119 | if (cliArgs.listen) { | 122 | if (cliArgs.listen) { |
| 120 | app.use(accessLoggerMiddleware()); | 123 | app.use(accessLoggerMiddleware()); |
| 121 | } | 124 | } |