diff --git a/.cspell.json b/.cspell.json index b964450..4aacc83 100644 --- a/.cspell.json +++ b/.cspell.json @@ -36,11 +36,13 @@ "filebase", "toplevel", "commitlint", - "tapable" + "tapable", + "nocheck" ], "ignorePaths": [ "CHANGELOG.md", "package.json", + "src/serialize-javascript.js", "dist/**", "**/__snapshots__/**", "package-lock.json", diff --git a/.prettierignore b/.prettierignore index f8f1e31..d704fb8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ +src/serialize-javascript.js /coverage /dist /node_modules /test/fixtures -CHANGELOG.md \ No newline at end of file +CHANGELOG.md diff --git a/eslint.config.mjs b/eslint.config.mjs index c227975..04ac47e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,7 @@ import configs from "eslint-config-webpack/configs.js"; export default defineConfig([ { + ignores: ["./src/serialize-javascript.js"], extends: [configs["recommended-dirty"]], }, ]); diff --git a/jest.config.js b/jest.config.js index 5da099d..6c107bc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,4 @@ module.exports = { testEnvironment: "node", + coveragePathIgnorePatterns: ["src/serialize-javascript.js"], }; diff --git a/package-lock.json b/package-lock.json index 0ac1d63..077ef37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "devDependencies": { @@ -41,6 +40,7 @@ "npm-run-all": "^4.1.5", "prettier": "^3.6.0", "prettier-2": "npm:prettier@^2", + "serialize-javascript": "^7.0.4", "standard-version": "^9.3.1", "typescript": "^5.9.2", "uglify-js": "^3.19.3", @@ -6811,6 +6811,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/copy-webpack-plugin/node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/core-js-compat": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", @@ -14954,6 +14964,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" @@ -15581,6 +15592,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -15682,12 +15694,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", + "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", + "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/set-function-length": { @@ -16793,6 +16806,16 @@ } } }, + "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -17708,6 +17731,7 @@ "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", diff --git a/package.json b/package.json index 4f94f2a..3ff5c3c 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,13 @@ "scripts": { "clean": "del-cli dist types", "prebuild": "npm run clean", + "build:serialize-javascript": "node ./scripts/copy-serialize-javascript.js", "build:types": "tsc --declaration --emitDeclarationOnly --outDir types && prettier \"types/**/*.ts\" --write", "build:code": "cross-env NODE_ENV=production babel src -d dist --copy-files", "build": "npm-run-all -p \"build:**\"", "commitlint": "commitlint --from=main", "security": "npm audit --production", + "lint:serialize-javascript": "node ./scripts/copy-serialize-javascript.js --check", "lint:prettier": "prettier --list-different .", "lint:code": "eslint --cache .", "lint:spelling": "cspell \"**/*.*\"", @@ -62,7 +64,6 @@ "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "devDependencies": { @@ -91,6 +92,7 @@ "npm-run-all": "^4.1.5", "prettier": "^3.6.0", "prettier-2": "npm:prettier@^2", + "serialize-javascript": "^7.0.4", "standard-version": "^9.3.1", "typescript": "^5.9.2", "uglify-js": "^3.19.3", diff --git a/scripts/copy-serialize-javascript.js b/scripts/copy-serialize-javascript.js new file mode 100644 index 0000000..4a57f22 --- /dev/null +++ b/scripts/copy-serialize-javascript.js @@ -0,0 +1,72 @@ +// eslint-disable-next-line n/no-unsupported-features/node-builtins +const fs = require("fs").promises; +const path = require("path"); + +/* eslint-disable no-console */ + +const randomBytesFallback = ` +var g = typeof globalThis !== 'undefined' ? globalThis : global; +var crypto = g.crypto || {}; + +if (typeof crypto.getRandomValues !== 'function') { + var nodeCrypto = require('crypto'); + + crypto.getRandomValues = function(typedArray) { + var bytes = nodeCrypto.randomBytes(typedArray.byteLength); + + new Uint8Array( + typedArray.buffer, + typedArray.byteOffset, + typedArray.byteLength + ).set(bytes); + + return typedArray; + }; +} +`; + +/** + * @param {string} src source path + * @param {string} dest destination path + * @returns {Promise} + */ +async function copyIfChanged(src, dest) { + let srcContent; + + try { + srcContent = await fs.readFile(src, "utf8"); + srcContent = `// @ts-nocheck\n${randomBytesFallback}${srcContent}`; + } catch (_err) { + srcContent = null; + } + + let destContent; + try { + destContent = await fs.readFile(dest, "utf8"); + } catch (_err) { + destContent = null; + } + + if ( + srcContent === null || + destContent === null || + srcContent !== destContent + ) { + if (process.argv.includes("--check")) { + throw new Error(`Content mismatch between ${src} and ${dest}`); + } + + await fs.writeFile(dest, srcContent); + console.log("File copied: content changed."); + } else { + console.log("No copying required: the content is identical."); + } +} + +const src = path.resolve( + __dirname, + "../node_modules/serialize-javascript/index.js", +); +const dest = path.resolve(__dirname, "../src/serialize-javascript.js"); + +copyIfChanged(src, dest); diff --git a/src/index.js b/src/index.js index acb38b2..bcae44a 100644 --- a/src/index.js +++ b/src/index.js @@ -163,7 +163,7 @@ const { */ const getTraceMapping = memoize(() => require("@jridgewell/trace-mapping")); -const getSerializeJavascript = memoize(() => require("serialize-javascript")); +const getSerializeJavascript = memoize(() => require("./serialize-javascript")); /** * @template [T=import("terser").MinifyOptions] diff --git a/src/serialize-javascript.js b/src/serialize-javascript.js new file mode 100644 index 0000000..b9ed78c --- /dev/null +++ b/src/serialize-javascript.js @@ -0,0 +1,319 @@ +// @ts-nocheck + +var g = typeof globalThis !== 'undefined' ? globalThis : global; +var crypto = g.crypto || {}; + +if (typeof crypto.getRandomValues !== 'function') { + var nodeCrypto = require('crypto'); + + crypto.getRandomValues = function(typedArray) { + // Генерируем буфер случайных байтов нужной длины + var bytes = nodeCrypto.randomBytes(typedArray.byteLength); + + // Копируем байты в типизированный массив через Uint8Array View + new Uint8Array( + typedArray.buffer, + typedArray.byteOffset, + typedArray.byteLength + ).set(bytes); + + return typedArray; + }; +} +/* +Copyright (c) 2014, Yahoo! Inc. All rights reserved. +Copyrights licensed under the New BSD License. +See the accompanying LICENSE file for terms. +*/ + +'use strict'; + +// Generate an internal UID to make the regexp pattern harder to guess. +var UID_LENGTH = 16; +var UID = generateUID(); +var PLACE_HOLDER_REGEXP = new RegExp('(\\\\)?"@__(F|R|D|M|S|A|U|I|B|L)-' + UID + '-(\\d+)__@"', 'g'); + +var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g; +var IS_PURE_FUNCTION = /function.*?\(/; +var IS_ARROW_FUNCTION = /.*?=>.*?/; +var UNSAFE_CHARS_REGEXP = /[<>\/\u2028\u2029]/g; +// Regex to match and variations (case-insensitive) for XSS protection +// Matches +var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>/gi; + +var RESERVED_SYMBOLS = ['*', 'async']; + +// Mapping of unsafe HTML and invalid JavaScript line terminator chars to their +// Unicode char counterparts which are safe to use in JavaScript strings. +var ESCAPED_CHARS = { + '<' : '\\u003C', + '>' : '\\u003E', + '/' : '\\u002F', + '\u2028': '\\u2028', + '\u2029': '\\u2029' +}; + +function escapeUnsafeChars(unsafeChar) { + return ESCAPED_CHARS[unsafeChar]; +} + +// Escape function body for XSS protection while preserving arrow function syntax +function escapeFunctionBody(str) { + // Escape sequences and variations (case-insensitive) - the main XSS risk + // Matches + // This must be done first before other replacements + str = str.replace(SCRIPT_CLOSE_REGEXP, function(match) { + // Escape all <, /, and > characters in the closing script tag + return match.replace(//g, '\\u003E'); + }); + // Escape line terminators (these are always unsafe) + str = str.replace(/\u2028/g, '\\u2028'); + str = str.replace(/\u2029/g, '\\u2029'); + return str; +} + +function generateUID() { + var bytes = crypto.getRandomValues(new Uint8Array(UID_LENGTH)); + var result = ''; + for(var i=0; i) while escaping + if (options && options.unsafe !== true) { + serializedFn = escapeFunctionBody(serializedFn); + } + + // pure functions, example: {key: function() {}} + if(IS_PURE_FUNCTION.test(serializedFn)) { + return serializedFn; + } + + // arrow functions, example: arg1 => arg1+5 + if(IS_ARROW_FUNCTION.test(serializedFn)) { + return serializedFn; + } + + var argsStartsAt = serializedFn.indexOf('('); + var def = serializedFn.substr(0, argsStartsAt) + .trim() + .split(' ') + .filter(function(val) { return val.length > 0 }); + + var nonReservedSymbols = def.filter(function(val) { + return RESERVED_SYMBOLS.indexOf(val) === -1 + }); + + // enhanced literal objects, example: {key() {}} + if(nonReservedSymbols.length > 0) { + return (def.indexOf('async') > -1 ? 'async ' : '') + 'function' + + (def.join('').indexOf('*') > -1 ? '*' : '') + + serializedFn.substr(argsStartsAt); + } + + // arrow functions + return serializedFn; + } + + // Check if the parameter is function + if (options.ignoreFunction && typeof obj === "function") { + obj = undefined; + } + // Protects against `JSON.stringify()` returning `undefined`, by serializing + // to the literal string: "undefined". + if (obj === undefined) { + return String(obj); + } + + var str; + + // Creates a JSON string representation of the value. + // NOTE: Node 0.12 goes into slow mode with extra JSON.stringify() args. + if (options.isJSON && !options.space) { + str = JSON.stringify(obj); + } else { + str = JSON.stringify(obj, options.isJSON ? null : replacer, options.space); + } + + // Protects against `JSON.stringify()` returning `undefined`, by serializing + // to the literal string: "undefined". + if (typeof str !== 'string') { + return String(str); + } + + // Replace unsafe HTML and invalid JavaScript line terminator chars with + // their safe Unicode char counterpart. This _must_ happen before the + // regexps and functions are serialized and added back to the string. + if (options.unsafe !== true) { + str = str.replace(UNSAFE_CHARS_REGEXP, escapeUnsafeChars); + } + + if (functions.length === 0 && regexps.length === 0 && dates.length === 0 && maps.length === 0 && sets.length === 0 && arrays.length === 0 && undefs.length === 0 && infinities.length === 0 && bigInts.length === 0 && urls.length === 0) { + return str; + } + + // Replaces all occurrences of function, regexp, date, map and set placeholders in the + // JSON string with their string representations. If the original value can + // not be found, then `undefined` is used. + return str.replace(PLACE_HOLDER_REGEXP, function (match, backSlash, type, valueIndex) { + // The placeholder may not be preceded by a backslash. This is to prevent + // replacing things like `"a\"@__R--0__@"` and thus outputting + // invalid JS. + if (backSlash) { + return match; + } + + if (type === 'D') { + // Validate ISO string format to prevent code injection via spoofed toISOString() + var isoStr = String(dates[valueIndex].toISOString()); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(isoStr)) { + throw new TypeError('Invalid Date ISO string'); + } + return "new Date(\"" + isoStr + "\")"; + } + + if (type === 'R') { + // Sanitize flags to prevent code injection (only allow valid RegExp flag characters) + var flags = String(regexps[valueIndex].flags).replace(/[^gimsuydv]/g, ''); + return "new RegExp(" + serialize(regexps[valueIndex].source) + ", \"" + flags + "\")"; + } + + if (type === 'M') { + return "new Map(" + serialize(Array.from(maps[valueIndex].entries()), options) + ")"; + } + + if (type === 'S') { + return "new Set(" + serialize(Array.from(sets[valueIndex].values()), options) + ")"; + } + + if (type === 'A') { + return "Array.prototype.slice.call(" + serialize(Object.assign({ length: arrays[valueIndex].length }, arrays[valueIndex]), options) + ")"; + } + + if (type === 'U') { + return 'undefined' + } + + if (type === 'I') { + return infinities[valueIndex]; + } + + if (type === 'B') { + return "BigInt(\"" + bigInts[valueIndex] + "\")"; + } + + if (type === 'L') { + return "new URL(" + serialize(urls[valueIndex].toString(), options) + ")"; + } + + var fn = functions[valueIndex]; + + return serializeFunc(fn, options); + }); +} diff --git a/test/worker.test.js b/test/worker.test.js index 1858127..911ba8c 100644 --- a/test/worker.test.js +++ b/test/worker.test.js @@ -1,7 +1,6 @@ -import serialize from "serialize-javascript"; - -import { transform } from "../src/minify"; -import { terserMinify } from "../src/utils"; +import { transform } from "../src/minify.js"; +import serialize from "../src/serialize-javascript.js"; +import { terserMinify } from "../src/utils.js"; describe("worker", () => { it('should match snapshot when options.extractComments is "false"', async () => { diff --git a/types/serialize-javascript.d.ts b/types/serialize-javascript.d.ts new file mode 100644 index 0000000..9617398 --- /dev/null +++ b/types/serialize-javascript.d.ts @@ -0,0 +1,2 @@ +declare function _exports(obj: any, options: any): any; +export = _exports;