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);
});