diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0d1375..e76bfa90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ planned for 2026-01-01 - feat: add ESlint rule `no-sparse-arrays` for config check to fix #3910 (#3911) - fixed eslint warnings shown in #3911 and updated npm publish docs (#3913) +- [core] refactor: replace `express-ipfilter` with lightweight custom middleware (#3917) - This fixes security issue [CVE-2023-42282](https://github.com/advisories/GHSA-78xj-cgh5-2h22), which is not very likely to be exploitable in MagicMirror² setups, but still should be fixed. ### Updated diff --git a/js/ip_access_control.js b/js/ip_access_control.js new file mode 100644 index 00000000..be1f1063 --- /dev/null +++ b/js/ip_access_control.js @@ -0,0 +1,63 @@ +const ipaddr = require("ipaddr.js"); +const Log = require("logger"); + +/** + * Checks if a client IP matches any entry in the whitelist + * @param {string} clientIp - The IP address to check + * @param {string[]} whitelist - Array of IP addresses or CIDR ranges + * @returns {boolean} True if IP is allowed + */ +function isAllowed (clientIp, whitelist) { + try { + const addr = ipaddr.process(clientIp); + + return whitelist.some((entry) => { + try { + // CIDR notation + if (entry.includes("/")) { + const [rangeAddr, prefixLen] = ipaddr.parseCIDR(entry); + return addr.match(rangeAddr, prefixLen); + } + + // Single IP address - let ipaddr.process normalize both + const allowedAddr = ipaddr.process(entry); + return addr.toString() === allowedAddr.toString(); + } catch (err) { + Log.warn(`Invalid whitelist entry: ${entry}`); + return false; + } + }); + } catch (err) { + Log.warn(`Failed to parse client IP: ${clientIp}`); + return false; + } +} + +/** + * Creates an Express middleware for IP whitelisting + * @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges + * @returns {import("express").RequestHandler} Express middleware function + */ +function ipAccessControl (whitelist) { + // Empty whitelist means allow all + if (!Array.isArray(whitelist) || whitelist.length === 0) { + return function (req, res, next) { + res.header("Access-Control-Allow-Origin", "*"); + next(); + }; + } + + return function (req, res, next) { + const clientIp = req.ip || req.socket.remoteAddress; + + if (isAllowed(clientIp, whitelist)) { + res.header("Access-Control-Allow-Origin", "*"); + next(); + } else { + Log.log(`IP ${clientIp} is not allowed to access the mirror`); + res.status(403).send("This device is not allowed to access your mirror.
Please check your config.js or config.js.sample to change this."); + } + }; +} + +module.exports = { ipAccessControl }; diff --git a/js/server.js b/js/server.js index fb17b906..281d4173 100644 --- a/js/server.js +++ b/js/server.js @@ -3,12 +3,13 @@ const http = require("node:http"); const https = require("node:https"); const path = require("node:path"); const express = require("express"); -const ipfilter = require("express-ipfilter").IpFilter; const helmet = require("helmet"); const socketio = require("socket.io"); const Log = require("logger"); const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("#server_functions"); +const { ipAccessControl } = require(`${__dirname}/ip_access_control`); + const vendor = require(`${__dirname}/vendor`); /** @@ -84,17 +85,7 @@ function Server (config) { Log.warn("You're using a full whitelist configuration to allow for all IPs"); } - app.use(function (req, res, next) { - ipfilter(config.ipWhitelist, { mode: config.ipWhitelist.length === 0 ? "deny" : "allow", log: false })(req, res, function (err) { - if (err === undefined) { - res.header("Access-Control-Allow-Origin", "*"); - return next(); - } - Log.log(err.message); - res.status(403).send("This device is not allowed to access your mirror.
Please check your config.js or config.js.sample to change this."); - }); - }); - + app.use(ipAccessControl(config.ipWhitelist)); app.use(helmet(config.httpHeaders)); app.use("/js", express.static(__dirname)); diff --git a/package-lock.json b/package-lock.json index 49753610..6dc197ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,11 +20,11 @@ "envsub": "^4.1.0", "eslint": "^9.37.0", "express": "^5.1.0", - "express-ipfilter": "^1.3.2", "feedme": "^2.0.2", "helmet": "^8.1.0", "html-to-text": "^9.0.5", "iconv-lite": "^0.7.0", + "ipaddr.js": "^2.2.0", "moment": "^2.30.1", "moment-timezone": "^0.6.0", "node-ical": "^0.21.0", @@ -156,7 +156,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -886,8 +885,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.18.tgz", "integrity": "sha512-EF77RqROHL+4LhMGW5NTeKqfUd/e4OOv6EDFQ/UQQiFyWuqkEKyEz0NDILxOFxWUEVdjT2GQ2cC7t12B6pESwg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-dart": { "version": "2.3.1", @@ -1027,16 +1025,14 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.12.tgz", "integrity": "sha512-JFffQ1dDVEyJq6tCDWv0r/RqkdSnV43P2F/3jJ9rwLgdsOIXwQbXrz6QDlvQLVvNSnORH9KjDtenFTGDyzfCaA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.4.tgz", "integrity": "sha512-afea+0rGPDeOV9gdO06UW183Qg6wRhWVkgCFwiO3bDupAoyXRuvupbb5nUyqSTsLXIKL8u8uXQlJ9pkz07oVXw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -1234,8 +1230,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -1376,7 +1371,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1423,7 +1417,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3249,7 +3242,6 @@ "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.1", @@ -3586,7 +3578,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4139,7 +4130,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -4436,7 +4426,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6070,7 +6059,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6571,21 +6559,6 @@ "basic-auth": "^2.0.1" } }, - "node_modules/express-ipfilter": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/express-ipfilter/-/express-ipfilter-1.3.2.tgz", - "integrity": "sha512-yMzCWGuVMnR8CFlsIC2spHWoQYp9vtyZXUgS/JdV5GOJgrz6zmKOEZsA4eF1XrxkOIVzaVk6yzTBk65pBhliNw==", - "license": "MIT", - "dependencies": { - "ip": "^2.0.1", - "lodash": "^4.17.11", - "proxy-addr": "^2.0.7", - "range_check": "^2.0.4" - }, - "engines": { - "node": ">=8.9.0" - } - }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -7897,12 +7870,6 @@ "node": ">= 0.4" } }, - "node_modules/ip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", - "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", - "license": "MIT" - }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -7912,22 +7879,13 @@ "node": ">= 12" } }, - "node_modules/ip6": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/ip6/-/ip6-0.2.11.tgz", - "integrity": "sha512-OmTP7FyIp+ZoNvZ7Xr97bWrCgypa3BeuYuRFNTOPT8Y11cxMW1pW1VC70kHZP1onSHHMotADcjdg5QyECiIMUw==", - "license": "MIT", - "bin": { - "ip6": "ip6-cli.js" - } - }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/is-alphabetical": { @@ -8544,7 +8502,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9274,6 +9231,7 @@ "integrity": "sha512-uuPNLJkKN8NXAlZlQ6kmUF9qO+T6Kyd7oV4+/7yy8Jz6+MZNyhPq8EdLpdfnPVzUC8qSf1b4j1azKaGnFsjmsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", @@ -9293,6 +9251,7 @@ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -9306,6 +9265,7 @@ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -9755,7 +9715,6 @@ "integrity": "sha512-/4Osri9QFGCZOCTkfA8qJF+XGjKYERSHkXzxSyS1hd3ZERJGjvsUao2h4wdnvpHp6Tu2Jh/bPHM0FE9JJza6ng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "globby": "14.1.0", "js-yaml": "4.1.0", @@ -11726,7 +11685,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11776,7 +11734,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -11807,7 +11764,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11901,6 +11857,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-agent": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", @@ -12044,19 +12009,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/range_check": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/range_check/-/range_check-2.0.4.tgz", - "integrity": "sha512-aed0ocXXj+SIiNNN9b+mZWA3Ow2GXHtftOGk2xQwshK5GbEZAvUcPWNQBLTx/lPcdFRIUFlFCRtHTQNIFMqynQ==", - "license": "BSD-2-Clause", - "dependencies": { - "ip6": "^0.2.0", - "ipaddr.js": "^1.9.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -13517,7 +13469,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -14382,7 +14333,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, diff --git a/package.json b/package.json index 6d9aad55..2305e0f0 100644 --- a/package.json +++ b/package.json @@ -79,11 +79,11 @@ "envsub": "^4.1.0", "eslint": "^9.37.0", "express": "^5.1.0", - "express-ipfilter": "^1.3.2", "feedme": "^2.0.2", "helmet": "^8.1.0", "html-to-text": "^9.0.5", "iconv-lite": "^0.7.0", + "ipaddr.js": "^2.2.0", "moment": "^2.30.1", "moment-timezone": "^0.6.0", "node-ical": "^0.21.0", diff --git a/tests/e2e/ipWhitelist_spec.js b/tests/e2e/ipWhitelist_spec.js index 07a0425e..ee7b6c8c 100644 --- a/tests/e2e/ipWhitelist_spec.js +++ b/tests/e2e/ipWhitelist_spec.js @@ -1,7 +1,7 @@ const helpers = require("./helpers/global-setup"); describe("ipWhitelist directive configuration", () => { - describe("Set ipWhitelist without access", () => { + describe("When IP is not in whitelist", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/noIpWhiteList.js"); }); @@ -9,13 +9,13 @@ describe("ipWhitelist directive configuration", () => { await helpers.stopApplication(); }); - it("should return 403", async () => { + it("should reject request with 403 (Forbidden)", async () => { const res = await fetch("http://localhost:8181"); expect(res.status).toBe(403); }); }); - describe("Set ipWhitelist []", () => { + describe("When whitelist is empty (allow all IPs)", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/empty_ipWhiteList.js"); }); @@ -23,7 +23,7 @@ describe("ipWhitelist directive configuration", () => { await helpers.stopApplication(); }); - it("should return 200", async () => { + it("should allow request with 200 (OK)", async () => { const res = await fetch("http://localhost:8282"); expect(res.status).toBe(200); });