From c1900ee12832ac8bcb10bb6a1800ca6d471af3d0 Mon Sep 17 00:00:00 2001 From: Dorian Niemiec Date: Sat, 24 Aug 2024 20:32:06 +0200 Subject: [PATCH] Add non-standard codes and HTTP authentication middleware, and SHA256 utility function. --- src/index.js | 3 +- .../nonStandardCodesAndHttpAuthentication.js | 453 ++++++++++++++++++ src/utils/sha256.js | 236 +++++++++ tests/utils/sha256.test.js | 59 +++ 4 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 src/middleware/nonStandardCodesAndHttpAuthentication.js create mode 100644 src/utils/sha256.js create mode 100644 tests/utils/sha256.test.js diff --git a/src/index.js b/src/index.js index 34f4cf9..0df44bc 100644 --- a/src/index.js +++ b/src/index.js @@ -184,7 +184,8 @@ let middleware = [ require("./middleware/webRootPostfixes.js"), require("./middleware/rewriteURL.js"), require("./middleware/responseHeaders.js"), - require("./middleware/checkForbiddenPaths.js") + require("./middleware/checkForbiddenPaths.js"), + require("./middleware/nonStandardCodesAndHttpAuthentication.js") ]; function addMiddleware(mw) { diff --git a/src/middleware/nonStandardCodesAndHttpAuthentication.js b/src/middleware/nonStandardCodesAndHttpAuthentication.js new file mode 100644 index 0000000..16bc0a5 --- /dev/null +++ b/src/middleware/nonStandardCodesAndHttpAuthentication.js @@ -0,0 +1,453 @@ +const os = require("os"); +const sha256 = require("../utils/sha256.js"); +const createRegex = require("../utils/createRegex.js"); +const ipMatch = require("../utils/ipMatch.js"); +const matchHostname = require("../utils/matchHostname.js"); +const cluster = require("../utils/clusterBunShim.js"); + +// Brute force protection-related +let bruteForceDb = {}; + +// PBKDF2/scrypt cache +let pbkdf2Cache = []; +let scryptCache = []; +let passwordHashCacheIntervalId = -1; + +if (!cluster.isPrimary) { + passwordHashCacheIntervalId = setInterval(function () { + pbkdf2Cache = pbkdf2Cache.filter(function (entry) { + return entry.addDate > new Date() - 3600000; + }); + scryptCache = scryptCache.filter(function (entry) { + return entry.addDate > new Date() - 3600000; + }); + }, 1800000); +} + +module.exports = (req, res, logFacilities, config, next) => { + let nonscodeIndex = -1; + let authIndex = -1; + let regexI = []; + let hrefWithoutDuplicateSlashes = ""; + const reqip = req.socket.realRemoteAddress + ? req.socket.realRemoteAddress + : req.socket.remoteAddress; + + // Scan for non-standard codes + if (!req.isProxy && config.nonStandardCodes != undefined) { + for (let i = 0; i < config.nonStandardCodes.length; i++) { + if ( + matchHostname(config.nonStandardCodes[i].host, req.headers.host) && + ipMatch( + config.nonStandardCodes[i].ip, + req.socket ? req.socket.localAddress : undefined, + ) + ) { + let isMatch = false; + hrefWithoutDuplicateSlashes = req.parsedURL.pathname.replace( + /\/+/g, + "/", + ); + if (config.nonStandardCodes[i].regex) { + // Regex match + var createdRegex = createRegex( + config.nonStandardCodes[i].regex, + true, + ); + isMatch = + req.url.match(createdRegex) || + hrefWithoutDuplicateSlashes.match(createdRegex); + regexI[i] = createdRegex; + } else { + // Non-regex match + isMatch = + config.nonStandardCodes[i].url == hrefWithoutDuplicateSlashes || + (os.platform() == "win32" && + config.nonStandardCodes[i].url.toLowerCase() == + hrefWithoutDuplicateSlashes.toLowerCase()); + } + if (isMatch) { + if (config.nonStandardCodes[i].scode == 401) { + // HTTP authentication + if (authIndex == -1) { + authIndex = i; + } + } else { + if (nonscodeIndex == -1) { + if ( + (config.nonStandardCodes[i].scode == 403 || + config.nonStandardCodes[i].scode == 451) && + config.nonStandardCodes[i].users !== undefined + ) { + if (config.nonStandardCodes[i].users.check(reqip)) + nonscodeIndex = i; + } else { + nonscodeIndex = i; + } + } + } + } + } + } + } + + // Handle non-standard codes + if (nonscodeIndex > -1) { + let nonscode = config.nonStandardCodes[nonscodeIndex]; + if ( + nonscode.scode == 301 || + nonscode.scode == 302 || + nonscode.scode == 307 || + nonscode.scode == 308 + ) { + let location = ""; + if (regexI[nonscodeIndex]) { + location = req.url.replace(regexI[nonscodeIndex], nonscode.location); + if (location == req.url) { + // Fallback replacement + location = hrefWithoutDuplicateSlashes.replace( + regexI[nonscodeIndex], + nonscode.location, + ); + } + } else if ( + req.url.split("?")[1] == undefined || + req.url.split("?")[1] == null || + req.url.split("?")[1] == "" || + req.url.split("?")[1] == " " + ) { + location = nonscode.location; + } else { + location = nonscode.location + "?" + req.url.split("?")[1]; + } + res.redirect( + location, + nonscode.scode == 302 || nonscode.scode == 307, + nonscode.scode == 307 || nonscode.scode == 308, + ); + return; + } else { + res.error(nonscode.scode); + if (nonscode.scode == 403) { + logFacilities.errmessage("Content blocked."); + } else if (nonscode.scode == 410) { + logFacilities.errmessage("Content is gone."); + } else if (nonscode.scode == 418) { + logFacilities.errmessage("SVR.JS is always a teapot ;)"); + } else { + logFacilities.errmessage("Client fails receiving content."); + } + return; + } + } + + // Handle HTTP authentication + if (authIndex > -1) { + let authcode = config.nonStandardCodes[authIndex]; + + // Function to check if passwords match + const checkIfPasswordMatches = (list, password, callback, _i) => { + if (!_i) _i = 0; + const cb = function (hash) { + if (hash == list[_i].pass) { + callback(true); + } else if (_i >= list.length - 1) { + callback(false); + } else { + checkIfPasswordMatches(list, password, callback, _i + 1); + } + }; + let hashedPassword = sha256(password + list[_i].salt); + let cacheEntry = null; + if (list[_i].scrypt) { + if (!crypto.scrypt) { + res.error( + 500, + new Error( + "SVR.JS doesn't support scrypt-hashed passwords on Node.JS versions without scrypt hash support.", + ), + ); + return; + } else { + cacheEntry = scryptCache.find(function (entry) { + return ( + entry.password == hashedPassword && entry.salt == list[_i].salt + ); + }); + if (cacheEntry) { + cb(cacheEntry.hash); + } else { + crypto.scrypt( + password, + list[_i].salt, + 64, + function (err, derivedKey) { + if (err) { + res.error(500, err); + } else { + const key = derivedKey.toString("hex"); + scryptCache.push({ + hash: key, + password: hashedPassword, + salt: list[_i].salt, + addDate: new Date(), + }); + cb(key); + } + }, + ); + } + } + } else if (list[_i].pbkdf2) { + if (crypto.__disabled__ !== undefined) { + res.error( + 500, + new Error( + "SVR.JS doesn't support PBKDF2-hashed passwords on Node.JS versions without crypto support.", + ), + ); + return; + } else { + cacheEntry = pbkdf2Cache.find(function (entry) { + return ( + entry.password == hashedPassword && entry.salt == list[_i].salt + ); + }); + if (cacheEntry) { + cb(cacheEntry.hash); + } else { + crypto.pbkdf2( + password, + list[_i].salt, + 36250, + 64, + "sha512", + function (err, derivedKey) { + if (err) { + res.error(500, err); + } else { + const key = derivedKey.toString("hex"); + pbkdf2Cache.push({ + hash: key, + password: hashedPassword, + salt: list[_i].salt, + addDate: new Date(), + }); + cb(key); + } + }, + ); + } + } + } else { + cb(hashedPassword); + } + }; + + const authorizedCallback = (bruteProtection) => { + try { + const ha = config.getCustomHeaders(); + ha["WWW-Authenticate"] = + 'Basic realm="' + + (authcode.realm + ? authcode.realm.replace(/(\\|")/g, "\\$1") + : "SVR.JS HTTP Basic Authorization") + + '", charset="UTF-8"'; + const credentials = req.headers["authorization"]; + if (!credentials) { + res.error(401, ha); + logFacilities.errmessage("Content needs authorization."); + return; + } + const credentialsMatch = credentials.match(/^Basic (.+)$/); + if (!credentialsMatch) { + res.error(401, ha); + logFacilities.errmessage("Malformed credentials."); + return; + } + const decodedCredentials = Buffer.from( + credentialsMatch[1], + "base64", + ).toString("utf8"); + const decodedCredentialsMatch = + decodedCredentials.match(/^([^:]*):(.*)$/); + if (!decodedCredentialsMatch) { + res.error(401, ha); + logFacilities.errmessage("Malformed credentials."); + return; + } + const username = decodedCredentialsMatch[1]; + const password = decodedCredentialsMatch[2]; + let usernameMatch = []; + let sha256Count = 0; + let pbkdf2Count = 0; + let scryptCount = 0; + if (!authcode.userList || authcode.userList.indexOf(username) > -1) { + usernameMatch = config.users.filter(function (entry) { + if (entry.scrypt) { + scryptCount++; + } else if (entry.pbkdf2) { + pbkdf2Count++; + } else { + sha256Count++; + } + return entry.name == username; + }); + } + if (usernameMatch.length == 0) { + // Pushing false user match to prevent time-based user enumeration + let fakeCredentials = { + name: username, + pass: "SVRJSAWebServerRunningOnNodeJS", + salt: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0", + }; + if (!process.isBun) { + if (scryptCount > sha256Count && scryptCount > pbkdf2Count) { + fakeCredentials.scrypt = true; + } else if (pbkdf2Count > sha256Count) { + fakeCredentials.pbkdf2 = true; + } + } + usernameMatch.push(fakeCredentials); + } + checkIfPasswordMatches(usernameMatch, password, function (authorized) { + try { + if (!authorized) { + if (bruteProtection) { + if (process.send) { + process.send("\x12AUTHW" + reqip); + } else { + if (!bruteForceDb[reqip]) + bruteForceDb[reqip] = { + invalidAttempts: 0, + }; + bruteForceDb[reqip].invalidAttempts++; + if (bruteForceDb[reqip].invalidAttempts >= 10) { + bruteForceDb[reqip].lastAttemptDate = new Date(); + } + } + } + res.error(401, ha); + logFacilities.errmessage( + 'User "' + + String(username).replace(/[\r\n]/g, "") + + '" failed to log in.', + ); + } else { + if (bruteProtection) { + if (process.send) { + process.send("\x12AUTHR" + reqip); + } else { + if (bruteForceDb[reqip]) + bruteForceDb[reqip] = { + invalidAttempts: 0, + }; + } + } + logFacilities.reqmessage( + 'Client is logged in as "' + + String(username).replace(/[\r\n]/g, "") + + '".', + ); + req.authUser = username; + next(); + } + } catch (err) { + res.error(500, err); + return; + } + }); + } catch (err) { + res.error(500, err); + return; + } + }; + if (authcode.disableBruteProtection) { + // Don't brute-force protect it, just do HTTP authentication + authorizedCallback(false); + } else if (!process.send) { + // Query data from JS object database + if ( + !bruteForceDb[reqip] || + !bruteForceDb[reqip].lastAttemptDate || + new Date() - 300000 >= bruteForceDb[reqip].lastAttemptDate + ) { + if (bruteForceDb[reqip] && bruteForceDb[reqip].invalidAttempts >= 10) + bruteForceDb[reqip] = { + invalidAttempts: 5, + }; + authorizedCallback(true); + } else { + res.error(429); + logFacilities.errmessage("Brute force limit reached!"); + } + } else { + var listenerEmitted = false; + + // Listen for brute-force protection response + const authMessageListener = (message) => { + if (listenerEmitted) return; + if (message == "\x14AUTHA" + reqip || message == "\x14AUTHD" + reqip) { + process.removeListener("message", authMessageListener); + listenerEmitted = true; + } + if (message == "\x14AUTHD" + reqip) { + res.error(429); + logFacilities.errmessage("Brute force limit reached!"); + } else if (message == "\x14AUTHA" + reqip) { + authorizedCallback(true); + } + }; + process.on("message", authMessageListener); + process.send("\x12AUTHQ" + reqip); + } + } else { + next(); + } +}; + +// IPC listener for brute force protection +module.exports.mainMessageListenerWrapper = (worker) => { + return function bruteForceListener(message) { + let ip = ""; + if (message.substring(0, 6) == "\x12AUTHQ") { + ip = message.substring(6); + if ( + !bruteForceDb[ip] || + !bruteForceDb[ip].lastAttemptDate || + new Date() - 300000 >= bruteForceDb[ip].lastAttemptDate + ) { + if (bruteForceDb[ip] && bruteForceDb[ip].invalidAttempts >= 10) + bruteForceDb[ip] = { + invalidAttempts: 5, + }; + worker.send("\x14AUTHA" + ip); + } else { + worker.send("\x14AUTHD" + ip); + } + } else if (message.substring(0, 6) == "\x12AUTHR") { + ip = message.substring(6); + if (bruteForceDb[ip]) + bruteForceDb[ip] = { + invalidAttempts: 0, + }; + } else if (message.substring(0, 6) == "\x12AUTHW") { + ip = message.substring(6); + if (!bruteForceDb[ip]) + bruteForceDb[ip] = { + invalidAttempts: 0, + }; + bruteForceDb[ip].invalidAttempts++; + if (bruteForceDb[ip].invalidAttempts >= 10) { + bruteForceDb[ip].lastAttemptDate = new Date(); + } + } + }; +}; + +module.exports.commands = { + stop: (args, passCommand) => { + clearInterval(passwordHashCacheIntervalId); + passCommand(args); + }, +}; diff --git a/src/utils/sha256.js b/src/utils/sha256.js new file mode 100644 index 0000000..ff54cfa --- /dev/null +++ b/src/utils/sha256.js @@ -0,0 +1,236 @@ +let crypto = { __disabled__: null }; +try { + crypto = require("crypto"); +} catch (err) { + // Crypto support is disabled. +} + +// SHA256 function +function sha256(s) { + if (crypto.__disabled__ === undefined) { + let hash = crypto.createHash("SHA256"); + hash.update(s); + return hash.digest("hex"); + } else { + const chrsz = 8; + const hexcase = 0; + + const safeAdd = (x, y) => { + const lsw = (x & 0xffff) + (y & 0xffff); + const msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xffff); + }; + + const S = (X, n) => { + return (X >>> n) | (X << (32 - n)); + }; + + const R = (X, n) => { + return X >>> n; + }; + + const Ch = (x, y, z) => { + return (x & y) ^ (~x & z); + }; + + const Maj = (x, y, z) => { + return (x & y) ^ (x & z) ^ (y & z); + }; + + const Sigma0256 = (x) => { + return S(x, 2) ^ S(x, 13) ^ S(x, 22); + }; + + const Sigma1256 = (x) => { + return S(x, 6) ^ S(x, 11) ^ S(x, 25); + }; + + const Gamma0256 = (x) => { + return S(x, 7) ^ S(x, 18) ^ R(x, 3); + }; + + const Gamma1256 = (x) => { + return S(x, 17) ^ S(x, 19) ^ R(x, 10); + }; + + function coreSha256(m, l) { + const K = new Array( + 0x428a2f98, + 0x71374491, + 0xb5c0fbcf, + 0xe9b5dba5, + 0x3956c25b, + 0x59f111f1, + 0x923f82a4, + 0xab1c5ed5, + 0xd807aa98, + 0x12835b01, + 0x243185be, + 0x550c7dc3, + 0x72be5d74, + 0x80deb1fe, + 0x9bdc06a7, + 0xc19bf174, + 0xe49b69c1, + 0xefbe4786, + 0xfc19dc6, + 0x240ca1cc, + 0x2de92c6f, + 0x4a7484aa, + 0x5cb0a9dc, + 0x76f988da, + 0x983e5152, + 0xa831c66d, + 0xb00327c8, + 0xbf597fc7, + 0xc6e00bf3, + 0xd5a79147, + 0x6ca6351, + 0x14292967, + 0x27b70a85, + 0x2e1b2138, + 0x4d2c6dfc, + 0x53380d13, + 0x650a7354, + 0x766a0abb, + 0x81c2c92e, + 0x92722c85, + 0xa2bfe8a1, + 0xa81a664b, + 0xc24b8b70, + 0xc76c51a3, + 0xd192e819, + 0xd6990624, + 0xf40e3585, + 0x106aa070, + 0x19a4c116, + 0x1e376c08, + 0x2748774c, + 0x34b0bcb5, + 0x391c0cb3, + 0x4ed8aa4a, + 0x5b9cca4f, + 0x682e6ff3, + 0x748f82ee, + 0x78a5636f, + 0x84c87814, + 0x8cc70208, + 0x90befffa, + 0xa4506ceb, + 0xbef9a3f7, + 0xc67178f2, + ); + let HASH = new Array( + 0x6a09e667, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19, + ); + let W = new Array(64); + let a, b, c, d, e, f, g, h, i, j; + let T1, T2; + + m[l >> 5] |= 0x80 << (24 - (l % 32)); + m[(((l + 64) >> 9) << 4) + 15] = l; + + for (let i = 0; i < m.length; i += 16) { + a = HASH[0]; + b = HASH[1]; + c = HASH[2]; + d = HASH[3]; + e = HASH[4]; + f = HASH[5]; + g = HASH[6]; + h = HASH[7]; + + for (let j = 0; j < 64; j++) { + if (j < 16) W[j] = m[j + i]; + else + W[j] = safeAdd( + safeAdd( + safeAdd(Gamma1256(W[j - 2]), W[j - 7]), + Gamma0256(W[j - 15]), + ), + W[j - 16], + ); + + T1 = safeAdd( + safeAdd(safeAdd(safeAdd(h, Sigma1256(e)), Ch(e, f, g)), K[j]), + W[j], + ); + T2 = safeAdd(Sigma0256(a), Maj(a, b, c)); + + h = g; + g = f; + f = e; + e = safeAdd(d, T1); + d = c; + c = b; + b = a; + a = safeAdd(T1, T2); + } + + HASH[0] = safeAdd(a, HASH[0]); + HASH[1] = safeAdd(b, HASH[1]); + HASH[2] = safeAdd(c, HASH[2]); + HASH[3] = safeAdd(d, HASH[3]); + HASH[4] = safeAdd(e, HASH[4]); + HASH[5] = safeAdd(f, HASH[5]); + HASH[6] = safeAdd(g, HASH[6]); + HASH[7] = safeAdd(h, HASH[7]); + } + return HASH; + } + + const str2binb = (str) => { + let bin = Array(); + const mask = (1 << chrsz) - 1; + for (let i = 0; i < str.length * chrsz; i += chrsz) { + bin[i >> 5] |= (str.charCodeAt(i / chrsz) & mask) << (24 - (i % 32)); + } + return bin; + }; + + const Utf8Encode = (string) => { + string = string.replace(/\r\n/g, "\n"); + let utftext = ""; + + for (let n = 0; n < string.length; n++) { + let c = string.charCodeAt(n); + + if (c < 128) { + utftext += String.fromCharCode(c); + } else if (c > 127 && c < 2048) { + utftext += String.fromCharCode((c >> 6) | 192); + utftext += String.fromCharCode((c & 63) | 128); + } else { + utftext += String.fromCharCode((c >> 12) | 224); + utftext += String.fromCharCode(((c >> 6) & 63) | 128); + utftext += String.fromCharCode((c & 63) | 128); + } + } + + return utftext; + }; + + const binb2hex = (binarray) => { + const hexTab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; + let str = ""; + for (let i = 0; i < binarray.length * 4; i++) { + str += + hexTab.charAt((binarray[i >> 2] >> ((3 - (i % 4)) * 8 + 4)) & 0xf) + + hexTab.charAt((binarray[i >> 2] >> ((3 - (i % 4)) * 8)) & 0xf); + } + return str; + }; + + s = Utf8Encode(s); + return binb2hex(coreSha256(str2binb(s), s.length * chrsz)); + } +} + +module.exports = sha256; diff --git a/tests/utils/sha256.test.js b/tests/utils/sha256.test.js new file mode 100644 index 0000000..543875b --- /dev/null +++ b/tests/utils/sha256.test.js @@ -0,0 +1,59 @@ +const sha256 = require("../../src/utils/sha256"); +const crypto = require("crypto"); + +// Mock the crypto module to simulate the absence of crypto support +jest.mock("crypto", () => ({ + createHash: jest.fn(() => ({ + update: jest.fn(), + digest: jest.fn(() => "mockedHash"), + })), +})); + +describe("SHA256 hash", () => { + test("should use crypto module if available", () => { + const result = sha256("test"); + expect(result).toBe("mockedHash"); + expect(crypto.createHash).toHaveBeenCalledWith("SHA256"); + }); + + test("should fallback to manual SHA256 implementation if crypto is disabled", () => { + crypto.__disabled__ = null; + const result = sha256("test"); + expect(result).toBe( + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + ); + }); + + test("should handle empty string", () => { + crypto.__disabled__ = null; + const result = sha256(""); + expect(result).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + }); + + test("should handle special characters", () => { + crypto.__disabled__ = null; + const result = sha256("!@#$%^&*()"); + expect(result).toBe( + "95ce789c5c9d18490972709838ca3a9719094bca3ac16332cfec0652b0236141", + ); + }); + + test("should handle long strings", () => { + crypto.__disabled__ = null; + const longString = "a".repeat(1000); + const result = sha256(longString); + expect(result).toBe( + "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3", + ); + }); + + test("should handle non-ASCII characters", () => { + crypto.__disabled__ = null; + const result = sha256("éñ"); + expect(result).toBe( + "c53435f74d8215688e74112f1c6527ad31fd3b72939769a75d09a14cd8a80cfe", + ); + }); +});