From fbdb3f93d4da16b74c09228876518e91d300e2d9 Mon Sep 17 00:00:00 2001 From: Dorian Niemiec Date: Fri, 23 Aug 2024 17:43:37 +0200 Subject: [PATCH] Add URL sanitizer. Also add eslint-plugin-jest to ESLint configuration. --- eslint.config.mjs | 26 +++- package-lock.json | 197 +++++++++++++++++++++++++++++++ package.json | 3 +- src/utils/urlSanitizer.js | 42 +++++++ tests/utils/helper.test.js | 4 +- tests/utils/urlSanitizer.test.js | 54 +++++++++ 6 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 src/utils/urlSanitizer.js create mode 100644 tests/utils/urlSanitizer.test.js diff --git a/eslint.config.mjs b/eslint.config.mjs index d0b40ec..fd22806 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,30 @@ import globals from "globals"; import pluginJs from "@eslint/js"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import jest from "eslint-plugin-jest"; export default [ - {files: ["**/*.js"], languageOptions: {sourceType: "commonjs"}}, - {languageOptions: { globals: {...globals.node} }}, + { + files: ["**/*.js"], + languageOptions: { + sourceType: "commonjs" + } + }, + { + files: ["tests/**/*.test.js"], + ...jest.configs['flat/recommended'], + rules: { + ...jest.configs['flat/recommended'].rules, + 'jest/prefer-expect-assertions': 'off', + } + }, + { + languageOptions: { + globals: { + ...globals.node + } + } + }, pluginJs.configs.recommended, - eslintPluginPrettierRecommended + eslintPluginPrettierRecommended, ]; diff --git a/package-lock.json b/package-lock.json index 3e36ba9..fef9421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "esbuild-plugin-copy": "^2.1.1", "eslint": "^9.9.0", "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jest": "^28.8.0", "eslint-plugin-prettier": "^5.2.1", "globals": "^15.9.0", "jest": "^29.7.0", @@ -1822,6 +1823,151 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.2.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2813,6 +2959,31 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-jest": { + "version": "28.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.0.tgz", + "integrity": "sha512-Tubj1hooFxCl52G4qQu0edzV/+EZzPUeN8p2NnW5uu4fbDs+Yo7+qDVDc4/oG3FbCqEBmu/OC3LSsyiU22oghw==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", + "jest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -5532,6 +5703,18 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -5571,6 +5754,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", diff --git a/package.json b/package.json index 69af877..d1516c5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "node esbuild.config.js", "dev": "npm run build && npm run start", - "lint": "eslint src/**/*.js", + "lint": "eslint src/**/*.js tests/**/*.test.js", "lint:fix": "npm run lint -- --fix", "start": "node dist/svr.js", "test": "jest" @@ -18,6 +18,7 @@ "esbuild-plugin-copy": "^2.1.1", "eslint": "^9.9.0", "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jest": "^28.8.0", "eslint-plugin-prettier": "^5.2.1", "globals": "^15.9.0", "jest": "^29.7.0", diff --git a/src/utils/urlSanitizer.js b/src/utils/urlSanitizer.js new file mode 100644 index 0000000..3ea6979 --- /dev/null +++ b/src/utils/urlSanitizer.js @@ -0,0 +1,42 @@ +function sanitizeURL(resource, allowDoubleSlashes) { + if (resource == "*" || resource == "") return resource; + // Remove null characters + resource = resource.replace(/%00|\0/g, ""); + // Check if URL is malformed (e.g. %c0%af or %u002f or simply %as) + if (resource.match(/%(?:c[01]|f[ef]|(?![0-9a-f]{2}).{2}|.{0,1}$)/i)) + throw new URIError("URI malformed"); + // Decode URL-encoded characters while preserving certain characters + resource = resource.replace(/%([0-9a-f]{2})/gi, (match, hex) => { + var decodedChar = String.fromCharCode(parseInt(hex, 16)); + return /(?!["<>^`{|}?#%])[!-~]/.test(decodedChar) ? decodedChar : "%" + hex; + }); + // Encode certain characters + resource = resource.replace(/[<>^`{|}]]/g, (character) => { + var charCode = character.charCodeAt(0); + return ( + "%" + (charCode < 16 ? "0" : "") + charCode.toString(16).toUpperCase() + ); + }); + var sanitizedResource = resource; + // Ensure the resource starts with a slash + if (resource[0] != "/") sanitizedResource = "/" + sanitizedResource; + // Convert backslashes to slashes and handle duplicate slashes + sanitizedResource = sanitizedResource + .replace(/\\/g, "/") + .replace(allowDoubleSlashes ? /\/{3,}/g : /\/+/g, "/"); + // Handle relative navigation (e.g., "/./", "/../", "../", "./"), also remove trailing dots in paths + sanitizedResource = sanitizedResource + .replace(/\/\.(?:\.{2,})?(?=\/|$)/g, "") + .replace(/([^.\/])\.+(?=\/|$)/g, "$1"); + while (sanitizedResource.match(/\/(?!\.\.\/)[^\/]+\/\.\.(?=\/|$)/)) { + sanitizedResource = sanitizedResource.replace( + /\/(?!\.\.\/)[^\/]+\/\.\.(?=\/|$)/g, + "", + ); + } + sanitizedResource = sanitizedResource.replace(/\/\.\.(?=\/|$)/g, ""); + if (sanitizedResource.length == 0) return "/"; + else return sanitizedResource; +} + +module.exports = sanitizeURL; diff --git a/tests/utils/helper.test.js b/tests/utils/helper.test.js index 6b8ba80..b5fe8c7 100644 --- a/tests/utils/helper.test.js +++ b/tests/utils/helper.test.js @@ -1,5 +1,5 @@ -const { add } = require('../../src/utils/helper'); +const { add } = require("../../src/utils/helper"); -test('adds 1 + 2 to equal 3', () => { +test("adds 1 + 2 to equal 3", () => { expect(add(1, 2)).toBe(3); }); diff --git a/tests/utils/urlSanitizer.test.js b/tests/utils/urlSanitizer.test.js new file mode 100644 index 0000000..79f5bd6 --- /dev/null +++ b/tests/utils/urlSanitizer.test.js @@ -0,0 +1,54 @@ +const sanitizeURL = require("../../src/utils/urlSanitizer.js"); + +describe("URL sanitizer", () => { + test('should return "*" for "*"', () => { + expect(sanitizeURL("*")).toBe("*"); + }); + + test("should return empty string for empty string", () => { + expect(sanitizeURL("")).toBe(""); + }); + + test("should remove null characters", () => { + expect(sanitizeURL("/test%00")).toBe("/test"); + expect(sanitizeURL("/test\0")).toBe("/test"); + }); + + test("should throw URIError for malformed URL", () => { + expect(() => sanitizeURL("%c0%af")).toThrow(URIError); + expect(() => sanitizeURL("%u002f")).toThrow(URIError); + expect(() => sanitizeURL("%as")).toThrow(URIError); + }); + + test("should ensure the resource starts with a slash", () => { + expect(sanitizeURL("test")).toBe("/test"); + }); + + test("should convert backslashes to slashes", () => { + expect(sanitizeURL("test\\path")).toBe("/test/path"); + }); + + test("should handle duplicate slashes", () => { + expect(sanitizeURL("test//path", false)).toBe("/test/path"); + expect(sanitizeURL("test//path", true)).toBe("/test//path"); + }); + + test("should handle relative navigation", () => { + expect(sanitizeURL("/./test")).toBe("/test"); + expect(sanitizeURL("/../test")).toBe("/test"); + expect(sanitizeURL("../test")).toBe("/test"); + expect(sanitizeURL("./test")).toBe("/test"); + expect(sanitizeURL("/test/./")).toBe("/test/"); + expect(sanitizeURL("/test/../")).toBe("/"); + expect(sanitizeURL("/test/../path")).toBe("/path"); + }); + + test("should remove trailing dots in paths", () => { + expect(sanitizeURL("/test...")).toBe("/test"); + expect(sanitizeURL("/test.../")).toBe("/test/"); + }); + + test('should return "/" for empty sanitized resource', () => { + expect(sanitizeURL("/../..")).toBe("/"); + }); +});