From 1929641ba7cf51e9ba28bf1f8df66f6010f500ef Mon Sep 17 00:00:00 2001 From: Dorian Niemiec Date: Sat, 24 Aug 2024 08:02:11 +0200 Subject: [PATCH] Add web root postfixes middleware --- src/index.js | 6 +- src/middleware/webRootPostfixes.js | 116 +++++++++++++++++++++++++++++ src/utils/createRegex.js | 12 +++ src/utils/ipMatch.js | 78 +++++++++++++++++++ tests/utils/createRegex.test.js | 75 +++++++++++++++++++ tests/utils/ipMatch.test.js | 60 +++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 src/middleware/webRootPostfixes.js create mode 100644 src/utils/createRegex.js create mode 100644 src/utils/ipMatch.js create mode 100644 tests/utils/createRegex.test.js create mode 100644 tests/utils/ipMatch.test.js diff --git a/src/index.js b/src/index.js index 91b14a8..390039a 100644 --- a/src/index.js +++ b/src/index.js @@ -85,7 +85,10 @@ if (configJSON.useWebRootServerSideScript === undefined) configJSON.useWebRootSe if (configJSON.exposeModsInErrorPages === undefined) configJSON.exposeModsInErrorPages = true; if (configJSON.disableTrailingSlashRedirects === undefined) configJSON.disableTrailingSlashRedirects = false; if (configJSON.environmentVariables === undefined) configJSON.environmentVariables = {}; +if (configJSON.wwwrootPostfixesVHost === undefined) configJSON.wwwrootPostfixesVHost = []; +if (configJSON.wwwrootPostfixPrefixesVHost === undefined) configJSON.wwwrootPostfixPrefixesVHost = []; if (configJSON.allowDoubleSlashes === undefined) configJSON.allowDoubleSlashes = false; +if (configJSON.allowPostfixDoubleSlashes === undefined) configJSON.allowPostfixDoubleSlashes = false; if (configJSON.optOutOfStatisticsServer === undefined) configJSON.optOutOfStatisticsServer = false; configJSON.version = version; // Compatiblity for very old SVR.JS mods @@ -102,7 +105,8 @@ const serverconsole = serverconsoleConstructor(configJSON.enableLogging); let middleware = [ require("./middleware/core.js"), require("./middleware/urlSanitizer.js"), - require("./middleware/redirects.js") + require("./middleware/redirects.js"), + require("./middleware/webRootPostfixes.js") ]; function addMiddleware(mw) { diff --git a/src/middleware/webRootPostfixes.js b/src/middleware/webRootPostfixes.js new file mode 100644 index 0000000..c8df4fa --- /dev/null +++ b/src/middleware/webRootPostfixes.js @@ -0,0 +1,116 @@ +const createRegex = require("../utils/createRegex.js"); +const ipMatch = require("../utils/ipMatch.js"); +const sanitizeURL = require("../utils/urlSanitizer.js"); + +module.exports = (req, res, logFacilities, config, next) => { + const matchHostname = (hostname) => { + if (typeof hostname == "undefined" || hostname == "*") { + return true; + } else if ( + req.headers.host && + hostname.indexOf("*.") == 0 && + hostname != "*." + ) { + const hostnamesRoot = hostname.substring(2); + if ( + req.headers.host == hostnamesRoot || + (req.headers.host.length > hostnamesRoot.length && + req.headers.host.indexOf("." + hostnamesRoot) == + req.headers.host.length - hostnamesRoot.length - 1) + ) { + return true; + } + } else if (req.headers.host && req.headers.host == hostname) { + return true; + } + return false; + }; + + // Add web root postfixes + if (!req.isProxy) { + let preparedReqUrl3 = (config.allowPostfixDoubleSlashes ? (req.parsedURL.pathname.replace(/\/+/,"/") + req.parsedURL.search + req.parsedURL.hash) : req.url); + let urlWithPostfix = preparedReqUrl3; + let postfixPrefix = ""; + config.wwwrootPostfixPrefixesVHost.every(function (currentPostfixPrefix) { + if (preparedReqUrl3.indexOf(currentPostfixPrefix) == 0) { + if (currentPostfixPrefix.match(/\/+$/)) postfixPrefix = currentPostfixPrefix.replace(/\/+$/, ""); + else if (urlWithPostfix.length == currentPostfixPrefix.length || urlWithPostfix[currentPostfixPrefix.length] == "?" || urlWithPostfix[currentPostfixPrefix.length] == "/" || urlWithPostfix[currentPostfixPrefix.length] == "#") postfixPrefix = currentPostfixPrefix; + else return true; + urlWithPostfix = urlWithPostfix.substring(postfixPrefix.length); + return false; + } else { + return true; + } + }); + config.wwwrootPostfixesVHost.every(function (postfixEntry) { + if (matchHostname(postfixEntry.host) && ipMatch(postfixEntry.ip, req.socket ? req.socket.localAddress : undefined) && !(postfixEntry.skipRegex && preparedReqUrl3.match(createRegex(postfixEntry.skipRegex)))) { + urlWithPostfix = postfixPrefix + "/" + postfixEntry.postfix + urlWithPostfix; + return false; + } else { + return true; + } + }); + if (urlWithPostfix != preparedReqUrl3) { + logFacilities.resmessage("Added web root postfix: " + req.url + " => " + urlWithPostfix); + req.url = urlWithPostfix; + try { + req.parsedURL = new URL( + req.url, + "http" + + (req.socket.encrypted ? "s" : "") + + "://" + + (req.headers.host + ? req.headers.host + : config.domain + ? config.domain + : "unknown.invalid"), + ); + } catch (err) { + res.error(400, err); + return; + } + + let href = req.parsedURL.pathname + req.parsedURL.search; + + var sHref = sanitizeURL(href, allowDoubleSlashes); + var preparedReqUrl2 = req.parsedURL.pathname + req.parsedURL.search + req.parsedURL.hash; + + if (req.url != preparedReqUrl2 || sHref != href.replace(/\/\.(?=\/|$)/g, "/").replace(/\/+/g, "/")) { + res.error(403); + logFacilities.errmessage("Content blocked."); + return; + } else if (sHref != href) { + var rewrittenAgainURL = new url.Url(); + rewrittenAgainURL.path = null; + rewrittenAgainURL.href = null; + rewrittenAgainURL.pathname = sHref; + rewrittenAgainURL.hostname = null; + rewrittenAgainURL.host = null; + rewrittenAgainURL.port = null; + rewrittenAgainURL.protocol = null; + rewrittenAgainURL.slashes = null; + rewrittenAgainURL = url.format(rewrittenAgainURL); + logFacilities.resmessage("URL sanitized: " + req.url + " => " + rewrittenAgainURL); + req.url = rewrittenAgainURL; + try { + req.parsedURL = new URL( + req.url, + "http" + + (req.socket.encrypted ? "s" : "") + + "://" + + (req.headers.host + ? req.headers.host + : config.domain + ? config.domain + : "unknown.invalid"), + ); + } catch (err) { + res.error(400, err); + return; + } + } + } + } + + next(); +}; \ No newline at end of file diff --git a/src/utils/createRegex.js b/src/utils/createRegex.js new file mode 100644 index 0000000..0a1c5a3 --- /dev/null +++ b/src/utils/createRegex.js @@ -0,0 +1,12 @@ +const os = require("os"); + +function createRegex(regex, isPath) { + var regexStrMatch = regex.match(/^\/((?:\\.|[^\/\\])*)\/([a-zA-Z0-9]*)$/); + if (!regexStrMatch) throw new Error("Invalid regular expression: " + regex); + var searchString = regexStrMatch[1]; + var modifiers = regexStrMatch[2]; + if (isPath && !modifiers.match(/i/i) && os.platform() == "win32") modifiers += "i"; + return new RegExp(searchString, modifiers); + } + + module.exports = createRegex; \ No newline at end of file diff --git a/src/utils/ipMatch.js b/src/utils/ipMatch.js new file mode 100644 index 0000000..eab27e8 --- /dev/null +++ b/src/utils/ipMatch.js @@ -0,0 +1,78 @@ +// Function to check if IPs are equal +function ipMatch(IP1, IP2) { + if (!IP1) return true; + if (!IP2) return false; + + // Function to normalize IPv4 address (remove leading zeros) + function normalizeIPv4Address(address) { + return address.replace(/(^|\.)(?:0(?!\.|$))+/g, "$1"); + } + + // Function to expand IPv6 address to full format + function expandIPv6Address(address) { + var fullAddress = ""; + var expandedAddress = ""; + var validGroupCount = 8; + var validGroupSize = 4; + + var ipv4 = ""; + var extractIpv4 = /([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/; + var validateIpv4 = /((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})/; + + if (validateIpv4.test(address)) { + var oldGroups = address.match(extractIpv4); + for (var i = 1; i < oldGroups.length; i++) { + ipv4 += ("00" + (parseInt(oldGroups[i], 10).toString(16))).slice(-2) + (i == 2 ? ":" : ""); + } + address = address.replace(extractIpv4, ipv4); + } + + if (address.indexOf("::") == -1) { + fullAddress = address; + } else { + var sides = address.split("::"); + var groupsPresent = 0; + sides.forEach(function (side) { + groupsPresent += side.split(":").length; + }); + fullAddress += sides[0] + ":"; + if (validGroupCount - groupsPresent > 1) { + fullAddress += "0000:".repeat(validGroupCount - groupsPresent); + } + fullAddress += sides[1]; + } + var groups = fullAddress.split(":"); + for (var i = 0; i < validGroupCount; i++) { + if (groups[i].length < validGroupSize) { + groups[i] = "0".repeat(validGroupSize - groups[i].length) + groups[i]; + } + expandedAddress += (i != validGroupCount - 1) ? groups[i] + ":" : groups[i]; + } + return expandedAddress; + } + + // Normalize or expand IP addresses + IP1 = IP1.toLowerCase(); + if (IP1 == "localhost") IP1 = "::1"; + if (IP1.indexOf("::ffff:") == 0) IP1 = IP1.substring(7); + if (IP1.indexOf(":") > -1) { + IP1 = expandIPv6Address(IP1); + } else { + IP1 = normalizeIPv4Address(IP1); + } + + IP2 = IP2.toLowerCase(); + if (IP2 == "localhost") IP2 = "::1"; + if (IP2.indexOf("::ffff:") == 0) IP2 = IP2.substring(7); + if (IP2.indexOf(":") > -1) { + IP2 = expandIPv6Address(IP2); + } else { + IP2 = normalizeIPv4Address(IP2); + } + + // Check if processed IPs are equal + if (IP1 == IP2) return true; + else return false; + } + + module.exports = ipMatch; \ No newline at end of file diff --git a/tests/utils/createRegex.test.js b/tests/utils/createRegex.test.js new file mode 100644 index 0000000..39dd671 --- /dev/null +++ b/tests/utils/createRegex.test.js @@ -0,0 +1,75 @@ +const createRegex = require('../../src/utils/createRegex'); +const os = require('os'); + +jest.mock('os', () => ({ + platform: jest.fn(), +})); + +describe('createRegex', () => { + beforeEach(() => { + os.platform.mockReset(); + }); + + test('should throw an error for invalid regular expression', () => { + expect(() => createRegex('invalid/regex', false)).toThrow('Invalid regular expression: invalid/regex'); + }); + + test('should create a regular expression without modifiers', () => { + const regex = createRegex('/test/', false); + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe('test'); + expect(regex.flags).toBe(''); + }); + + test('should create a regular expression with modifiers', () => { + const regex = createRegex('/test/gi', false); + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe('test'); + expect(regex.flags).toBe('gi'); + }); + + test('should add "i" modifier for paths on Windows', () => { + os.platform.mockReturnValue('win32'); + const regex = createRegex('/test/', true); + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe('test'); + expect(regex.flags).toBe('i'); + }); + + test('should not add "i" modifier for paths on non-Windows platforms', () => { + os.platform.mockReturnValue('linux'); + const regex = createRegex('/test/', true); + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe('test'); + expect(regex.flags).toBe(''); + }); + + test('should not add "i" modifier if already present', () => { + os.platform.mockReturnValue('win32'); + const regex = createRegex('/test/i', true); + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe('test'); + expect(regex.flags).toBe('i'); + }); + + test('should handle escaped characters in the search string', () => { + const regex = createRegex('/test\\/path/', false); + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe('test\\/path'); + expect(regex.flags).toBe(''); + }); + + test('should handle multiple modifiers', () => { + const regex = createRegex('/test/gim', false); + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe('test'); + expect(regex.flags).toBe('gim'); + }); + + test('should handle empty search string', () => { + const regex = createRegex('/^$/', false); + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe('^$'); + expect(regex.flags).toBe(''); + }); +}); diff --git a/tests/utils/ipMatch.test.js b/tests/utils/ipMatch.test.js new file mode 100644 index 0000000..644793d --- /dev/null +++ b/tests/utils/ipMatch.test.js @@ -0,0 +1,60 @@ +const ipMatch = require('../../src/utils/ipMatch'); + +describe('ipMatch', () => { + test('should return true if IP1 is empty', () => { + expect(ipMatch('', '192.168.1.1')).toBe(true); + }); + + test('should return false if IP2 is empty', () => { + expect(ipMatch('192.168.1.1', '')).toBe(false); + }); + + test('should return true if both IPs are empty', () => { + expect(ipMatch('', '')).toBe(true); + }); + + test('should return true if both IPs are the same IPv4 address', () => { + expect(ipMatch('192.168.1.1', '192.168.1.1')).toBe(true); + }); + + test('should return false if IPs are different IPv4 addresses', () => { + expect(ipMatch('192.168.1.1', '192.168.1.2')).toBe(false); + }); + + test('should normalize IPv4 addresses with leading zeros', () => { + expect(ipMatch('192.168.001.001', '192.168.1.1')).toBe(true); + }); + + test('should return true if both IPs are the same IPv6 address', () => { + expect(ipMatch('2001:0db8:85a3:0000:0000:8a2e:0370:7334', '2001:db8:85a3::8a2e:370:7334')).toBe(true); + }); + + test('should return false if IPs are different IPv6 addresses', () => { + expect(ipMatch('2001:0db8:85a3:0000:0000:8a2e:0370:7334', '2001:db8:85a3::8a2e:370:7335')).toBe(false); + }); + + test('should expand IPv6 addresses with "::"', () => { + expect(ipMatch('::1', '0:0:0:0:0:0:0:1')).toBe(true); + }); + + test('should handle IPv6 addresses with embedded IPv4 addresses', () => { + expect(ipMatch('::ffff:192.168.1.1', '192.168.1.1')).toBe(true); + }); + + test('should handle "localhost" as IPv6 loopback address', () => { + expect(ipMatch('localhost', '::1')).toBe(true); + }); + + test('should handle mixed case IP addresses', () => { + expect(ipMatch('192.168.1.1', '192.168.1.1')).toBe(true); + expect(ipMatch('2001:DB8:85A3::8A2E:370:7334', '2001:db8:85a3::8a2e:370:7334')).toBe(true); + }); + + test('should handle IPv6 addresses with leading zeros', () => { + expect(ipMatch('2001:0db8:85a3:0000:0000:8a2e:0370:7334', '2001:db8:85a3::8a2e:370:7334')).toBe(true); + }); + + test('should handle IPv6 addresses with mixed case and leading zeros', () => { + expect(ipMatch('2001:0DB8:85A3:0000:0000:8A2E:0370:7334', '2001:db8:85a3::8a2e:370:7334')).toBe(true); + }); +});