diff --git a/CHANGELOG.md b/CHANGELOG.md index b609c201..77a55714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ All major and minor version changes will be documented in this file. Details of ## Details +### [11.2.0] - 2026-06-17 +This release includes a security fix ([#2569]) +- Security: Chart operation prototype protection [@C85297] | [#2569] +- Update website references [@C85297] | [#2566] +- Fix: Add input validation for XOR Checksum blocksize (#2537) [@dweep-js] | [#2542] +- Fix: Reverse highlights unwind incorrectly [@kendallgoto] [@C85297] | [#2022] +- Fix Uint8Array concat crash in Parse IPv4 header [@Zish19] | [#2409] +- Fix typos and documentation errors (bytes→bits, wrong release link, spelling) [@qa2me] [@GCHQDeveloper581] | [#2404] +- Add integer check for alphabet size [@heapframe] [@GCHQDeveloper581] | [#2458] +- fix: validate hexdump width upper bound [@skyswordw] | [#2514] + ### [11.1.0] - 2026-06-13 This release includes a security fix ([#2557]) - Security: Add fix, and tests, for Lorem Ipsum DoS issue [@GCHQDeveloper581] | [#2557] @@ -707,6 +718,7 @@ Breaking changes: ## [4.0.0] - 2016-11-28 - Initial open source commit [@n1474335] | [b1d73a72](https://github.com/gchq/CyberChef/commit/b1d73a725dc7ab9fb7eb789296efd2b7e4b08306) +[11.2.0]: https://github.com/gchq/CyberChef/releases/tag/v11.2.0 [11.1.0]: https://github.com/gchq/CyberChef/releases/tag/v11.1.0 [11.0.0]: https://github.com/gchq/CyberChef/releases/tag/v11.0.0 [10.24.0]: https://github.com/gchq/CyberChef/releases/tag/v10.24.0 @@ -1001,6 +1013,11 @@ Breaking changes: [@Louis-Ladd]: https://github.com/Louis-Ladd [@Blank0120]: https://github.com/Blank0120 [@zachbowden]: https://github.com/zachbowden +[@dweep-js]: https://github.com/dweep-js +[@Zish19]: https://github.com/Zish19 +[@qa2me]: https://github.com/qa2me +[@heapframe]: https://github.com/heapframe +[@skyswordw]: https://github.com/skyswordw [8ad18b]: https://github.com/gchq/CyberChef/commit/8ad18bc7db6d9ff184ba3518686293a7685bf7b7 @@ -1364,4 +1381,12 @@ Breaking changes: [#2332]: https://github.com/gchq/CyberChef/pull/2332 [#2353]: https://github.com/gchq/CyberChef/pull/2353 [#2351]: https://github.com/gchq/CyberChef/pull/2351 +[#2569]: https://github.com/gchq/CyberChef/pull/2569 +[#2566]: https://github.com/gchq/CyberChef/pull/2566 +[#2542]: https://github.com/gchq/CyberChef/pull/2542 +[#2022]: https://github.com/gchq/CyberChef/pull/2022 +[#2409]: https://github.com/gchq/CyberChef/pull/2409 +[#2404]: https://github.com/gchq/CyberChef/pull/2404 +[#2458]: https://github.com/gchq/CyberChef/pull/2458 +[#2514]: https://github.com/gchq/CyberChef/pull/2514 diff --git a/Dockerfile b/Dockerfile index 5bab956e..f70ba966 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ # Modifier --platform=$BUILDPLATFORM limits the platform to "BUILDPLATFORM" during buildx multi-platform builds # This is because npm "chromedriver" package is not compatiable with all platforms # For more info see: https://docs.docker.com/build/building/multi-platform/#cross-compilation -FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:fb71d01345f11b708a3553c66e7c74074f2d506400ea81973343d915cb64eef0 AS builder +FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:156b55f92e98ccd5ef49578a8cea0df4679826564bad1c9d4ef04462b9f0ded6 AS builder WORKDIR /app @@ -27,7 +27,7 @@ RUN npm run build ######################################### # Package static build files into nginx # ######################################### -FROM nginxinc/nginx-unprivileged:stable-alpine@sha256:37f356a5eba5d187365b4f59cd6cc29f1f922ad18146d554b576a80983377e6a AS cyberchef +FROM nginxinc/nginx-unprivileged:stable-alpine@sha256:fafa1102c789119971b3d83f9293f1ef5526bc73583a12e13ff5cd1299ed8b6c AS cyberchef LABEL maintainer="GCHQ " diff --git a/SECURITY.md b/SECURITY.md index 92382460..90cdd750 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,11 +1,13 @@ # Security Policy -## Supported Versions +## Support CyberChef is supported on a best endeavours basis. Patches will be applied to the latest version rather than retroactively to older versions. To ensure you are using the most secure version of CyberChef, please make sure you have the [latest release](https://github.com/gchq/CyberChef/releases/latest). [The official website](https://gchq.github.io/CyberChef/) is always up to date. +No guarantee is offered for the correctness or security of CyberChef. In paticular, the security of cryptographic operations should not be relied upon. + ## Reporting a Vulnerability If you discover a vulnerability in CyberChef, please do not publicly disclose it, and do not create a GitHub issue. diff --git a/package-lock.json b/package-lock.json index 5125275a..46d2fa53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cyberchef", - "version": "11.1.0", + "version": "11.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cyberchef", - "version": "11.1.0", + "version": "11.2.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -21,7 +21,7 @@ "assert": "^2.1.0", "avsc": "^5.7.9", "bcryptjs": "^3.0.3", - "bignumber.js": "^11.1.3", + "bignumber.js": "^11.1.4", "blakejs": "^1.2.1", "bootstrap": "4.6.2", "bootstrap-colorpicker": "^3.4.0", @@ -39,7 +39,7 @@ "d3": "7.9.0", "d3-hexbin": "^0.2.2", "diff": "^9.0.0", - "dompurify": "^3.4.8", + "dompurify": "^3.4.11", "es6-promisify": "^7.0.0", "escodegen": "^2.1.0", "esprima": "^4.0.1", @@ -85,7 +85,7 @@ "path": "^0.12.7", "popper.js": "^1.16.1", "process": "^0.11.10", - "protobufjs": "^8.6.2", + "protobufjs": "^8.6.4", "punycode.js": "^2.3.1", "qr-image": "^3.2.0", "reflect-metadata": "^0.2.2", @@ -116,7 +116,7 @@ "@babel/runtime": "^7.29.7", "@codemirror/commands": "^6.10.3", "@codemirror/language": "^6.12.3", - "@codemirror/search": "^6.7.0", + "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.43.1", "@puppeteer/browsers": "3.0.4", @@ -159,7 +159,7 @@ "terser": "^5.48.0", "webpack": "^5.107.2", "webpack-bundle-analyzer": "^5.3.0", - "webpack-dev-server": "^5.2.4", + "webpack-dev-server": "^5.2.5", "webpack-node-externals": "^3.0.0", "worker-loader": "^3.0.8" }, @@ -1876,9 +1876,9 @@ } }, "node_modules/@codemirror/search": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", - "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", "dev": true, "license": "MIT", "dependencies": { @@ -5794,9 +5794,9 @@ } }, "node_modules/bignumber.js": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.3.tgz", - "integrity": "sha512-+esZiNSo6VgFokTsYX6mYqNJfFd/IczzZCd4Z7cR8e+AQWhvIcj6nqQ1h9814D9u/TApU0jjTVmfWL0Pd1ZBdA==", + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.4.tgz", + "integrity": "sha512-AJ9dSeaUGj2xu7tEwmdqb51dqdb633xo4njI9K8ZFfcLrNr0XN8/EPkkZUNaF9fkCblGt2zVwZymesUdGynEkQ==", "license": "MIT" }, "node_modules/binary-extensions": { @@ -8585,9 +8585,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", - "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -9865,17 +9865,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -10907,9 +10907,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -12561,14 +12561,14 @@ } }, "node_modules/launch-editor": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.1.tgz", - "integrity": "sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/lazystream": { @@ -15086,9 +15086,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "8.6.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.2.tgz", - "integrity": "sha512-CCERJxzRvKMeEdJSLwdQf40TXWNPc8M4RkN7j/lxY6FQB+4do8rETWqj60AqxP9n0XIsxnSefZ8uhAaGKg2njw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.4.tgz", + "integrity": "sha512-/+XMv9JalknuncEJSwsyEVlwcxVLKx2iaoSUXFZA86MJkdqyOdfrlB1sB7S6aKyUk9tl20YY+SgQe5J2sJHTcg==", "license": "BSD-3-Clause", "dependencies": { "long": "^5.3.2" @@ -18112,9 +18112,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", - "integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz", + "integrity": "sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8439ed01..f20fba60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cyberchef", - "version": "11.1.0", + "version": "11.2.0", "description": "The Cyber Swiss Army Knife for encryption, encoding, compression and data analysis.", "author": "GCHQ ", "homepage": "https://gchq.github.io/CyberChef", @@ -46,7 +46,7 @@ "@babel/runtime": "^7.29.7", "@codemirror/commands": "^6.10.3", "@codemirror/language": "^6.12.3", - "@codemirror/search": "^6.7.0", + "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.43.1", "@puppeteer/browsers": "3.0.4", @@ -89,7 +89,7 @@ "terser": "^5.48.0", "webpack": "^5.107.2", "webpack-bundle-analyzer": "^5.3.0", - "webpack-dev-server": "^5.2.4", + "webpack-dev-server": "^5.2.5", "webpack-node-externals": "^3.0.0", "worker-loader": "^3.0.8" }, @@ -105,7 +105,7 @@ "assert": "^2.1.0", "avsc": "^5.7.9", "bcryptjs": "^3.0.3", - "bignumber.js": "^11.1.3", + "bignumber.js": "^11.1.4", "blakejs": "^1.2.1", "bootstrap": "4.6.2", "bootstrap-colorpicker": "^3.4.0", @@ -123,7 +123,7 @@ "d3": "7.9.0", "d3-hexbin": "^0.2.2", "diff": "^9.0.0", - "dompurify": "^3.4.8", + "dompurify": "^3.4.11", "es6-promisify": "^7.0.0", "escodegen": "^2.1.0", "esprima": "^4.0.1", @@ -169,7 +169,7 @@ "path": "^0.12.7", "popper.js": "^1.16.1", "process": "^0.11.10", - "protobufjs": "^8.6.2", + "protobufjs": "^8.6.4", "punycode.js": "^2.3.1", "qr-image": "^3.2.0", "reflect-metadata": "^0.2.2", diff --git a/src/core/Ingredient.mjs b/src/core/Ingredient.mjs index 0dd31707..4f81f83b 100644 --- a/src/core/Ingredient.mjs +++ b/src/core/Ingredient.mjs @@ -32,6 +32,8 @@ class Ingredient { this.min = null; this.max = null; this.step = 1; + this.integer = false; + this.allowEmpty = true; if (ingredientConfig) { this._parseConfig(ingredientConfig); @@ -59,6 +61,70 @@ class Ingredient { this.min = ingredientConfig.min; this.max = ingredientConfig.max; this.step = ingredientConfig.step; + this.integer = typeof ingredientConfig.integer !== "undefined" ? !!ingredientConfig.integer : false; + this.allowEmpty = typeof ingredientConfig.allowEmpty !== "undefined" ? !!ingredientConfig.allowEmpty : true; + } + + + /** + * Validates the given value against the constraints of this ingredient. + * + * @param {*} val + * @returns {boolean} + */ + validate(val) { + if (this.disabled) return true; + + let checkVal = val; + if (this.type === "toggleString" && val && typeof val === "object" && "string" in val) { + checkVal = val.string; + } + + // 1. check if empty + let isEmpty = false; + if (checkVal === null || checkVal === undefined || checkVal === "") { + isEmpty = true; + } else if (typeof checkVal.length === "number" && checkVal.length === 0) { + isEmpty = true; + } + + if (isEmpty) { + if (this.allowEmpty === false) { + throw new OperationError(`${this.name} cannot be empty.`); + } + return true; + } + + // 2. maxLength check + if (typeof this.maxLength === "number" && checkVal !== null && checkVal !== undefined) { + if (typeof checkVal === "string" && checkVal.length > this.maxLength) { + throw new OperationError(`${this.name} length cannot exceed ${this.maxLength}.`); + } + if (Array.isArray(checkVal) && checkVal.length > this.maxLength) { + throw new OperationError(`${this.name} length cannot exceed ${this.maxLength}.`); + } + if (checkVal instanceof Uint8Array && checkVal.length > this.maxLength) { + throw new OperationError(`${this.name} length cannot exceed ${this.maxLength}.`); + } + } + + // 3. number checks + if (this.type === "number") { + if (val === null || val === undefined || isNaN(val)) { + throw new OperationError(`${this.name} must be a number.`); + } + if (this.integer && !Number.isInteger(val)) { + throw new OperationError(`${this.name} must be an integer.`); + } + if (typeof this.min === "number" && val < this.min) { + throw new OperationError(`${this.name} must be greater than or equal to ${this.min}.`); + } + if (typeof this.max === "number" && val > this.max) { + throw new OperationError(`${this.name} must be less than or equal to ${this.max}.`); + } + } + + return true; } diff --git a/src/core/Operation.mjs b/src/core/Operation.mjs index bbf8a811..703295c7 100755 --- a/src/core/Operation.mjs +++ b/src/core/Operation.mjs @@ -192,11 +192,30 @@ class Operation { if (typeof ing.min === "number") conf.min = ing.min; if (typeof ing.max === "number") conf.max = ing.max; if (ing.step) conf.step = ing.step; + if (typeof ing.integer !== "undefined") conf.integer = ing.integer; + if (typeof ing.allowEmpty !== "undefined") conf.allowEmpty = ing.allowEmpty; return conf; }); } + /** + * Validates the operation's ingredients against their defined constraints. + * + * @param {Object[]} [args] - Optional list of argument values to validate. If not provided, validates the current ingredient values. + * @returns {boolean} - True if valid, throws an OperationError if invalid. + */ + validateIngredients(args) { + const values = args || this.ingValues; + this._ingList.forEach((ing, i) => { + if (i < values.length) { + ing.validate(values[i]); + } + }); + return true; + } + + /** * Returns the value of the Operation as it should be displayed in a recipe config. * diff --git a/src/core/Recipe.mjs b/src/core/Recipe.mjs index 84c91d61..0886e994 100755 --- a/src/core/Recipe.mjs +++ b/src/core/Recipe.mjs @@ -212,6 +212,8 @@ class Recipe { self.sendProgressMessage(i + 1, this.opList.length); } + op.validateIngredients(op.ingValues); + if (op.flowControl) { // Package up the current state let state = { diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index b1f0224b..ef4212fa 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -559,7 +559,7 @@ "Scatter chart", "Series chart", "Heatmap chart", - "Extract Audio Metadata" + "Render PDF" ] }, { @@ -585,7 +585,8 @@ "HTML To Text", "Generate Lorem Ipsum", "Numberwang", - "XKCD Random Number" + "XKCD Random Number", + "Automated Validation Test Op" ] }, { @@ -652,4 +653,4 @@ "Comment" ] } -] +] \ No newline at end of file diff --git a/src/core/lib/Arithmetic.mjs b/src/core/lib/Arithmetic.mjs index 7c10855f..5fbc5771 100644 --- a/src/core/lib/Arithmetic.mjs +++ b/src/core/lib/Arithmetic.mjs @@ -108,14 +108,17 @@ export function mean(data) { * @returns {BigNumber} */ export function median(data) { - if ((data.length % 2) === 0 && data.length > 0) { + if (data.length > 0) { data.sort(function(a, b) { return a.minus(b); }); - const first = data[Math.floor(data.length / 2)]; - const second = data[Math.floor(data.length / 2) - 1]; - return mean([first, second]); - } else { + + if ((data.length % 2) === 0) { + const first = data[Math.floor(data.length / 2)]; + const second = data[Math.floor(data.length / 2) - 1]; + return mean([first, second]); + } + return data[Math.floor(data.length / 2)]; } } diff --git a/src/core/lib/Charts.mjs b/src/core/lib/Charts.mjs index 6cb63f60..a70a900f 100644 --- a/src/core/lib/Charts.mjs +++ b/src/core/lib/Charts.mjs @@ -153,7 +153,7 @@ export function getSeriesValues(input, recordDelimiter, fieldDelimiter, columnHe ); let xValues = new Set(); - const series = {}; + const series = Object.create(null); values.forEach(row => { const serie = row[0], @@ -163,14 +163,14 @@ export function getSeriesValues(input, recordDelimiter, fieldDelimiter, columnHe if (Number.isNaN(val)) throw new OperationError("Values must be numbers in base 10."); xValues.add(xVal); - if (typeof series[serie] === "undefined") series[serie] = {}; + if (typeof series[serie] === "undefined") series[serie] = Object.create(null); series[serie][xVal] = val; }); xValues = new Array(...xValues); const seriesList = []; - for (const seriesName in series) { + for (const seriesName of Object.keys(series)) { const serie = series[seriesName]; seriesList.push({name: seriesName, data: serie}); } diff --git a/src/core/lib/Protocol.mjs b/src/core/lib/Protocol.mjs index dfb8b197..1875e40c 100644 --- a/src/core/lib/Protocol.mjs +++ b/src/core/lib/Protocol.mjs @@ -8,6 +8,7 @@ import BigNumber from "bignumber.js"; import {toHexFast} from "../lib/Hex.mjs"; +import Utils from "../Utils.mjs"; /** * Recursively displays a JSON object as an HTML table @@ -25,15 +26,16 @@ export function objToTable(obj, nested=false) { Value `; - for (const key in obj) { - if (typeof obj[key] === "function") + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (typeof value === "function") continue; - html += `${key}`; - if (typeof obj[key] === "object") - html += `${objToTable(obj[key], true)}`; + html += `${Utils.escapeHtml(String(key))}`; + if (value !== null && typeof value === "object") + html += `${objToTable(value, true)}`; else - html += `${obj[key]}`; + html += `${Utils.escapeHtml(String(value))}`; html += ""; } html += ""; diff --git a/src/core/lib/TLVParser.mjs b/src/core/lib/TLVParser.mjs index cb8432c1..1afd052e 100644 --- a/src/core/lib/TLVParser.mjs +++ b/src/core/lib/TLVParser.mjs @@ -33,20 +33,29 @@ export default class TLVParser { * @returns {number} */ getLength() { + let bytesInLength = this.bytesInLength; + let bigEndian = false; + if (this.basicEncodingRules) { - const bit = this.input[this.location]; - if (bit & 0x80) { - this.bytesInLength = bit & ~0x80; + const firstLengthByte = this.input[this.location]; + this.location++; + + if (firstLengthByte & 0x80) { + bytesInLength = firstLengthByte & ~0x80; + bigEndian = true; } else { - this.location++; - return bit & ~0x80; + return firstLengthByte & ~0x80; } } let length = 0; - for (let i = 0; i < this.bytesInLength; i++) { - length += this.input[this.location] * Math.pow(Math.pow(2, 8), i); + for (let i = 0; i < bytesInLength; i++) { + if (bigEndian) { + length = (length << 8) + this.input[this.location]; + } else { + length += this.input[this.location] * Math.pow(Math.pow(2, 8), i); + } this.location++; } diff --git a/src/core/operations/AutomatedValidationTestOp.mjs b/src/core/operations/AutomatedValidationTestOp.mjs new file mode 100644 index 00000000..315eb417 --- /dev/null +++ b/src/core/operations/AutomatedValidationTestOp.mjs @@ -0,0 +1,78 @@ +/** + * @author CyberChef + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; + +/** + * Automated validation test operation + */ +class AutomatedValidationTestOp extends Operation { + + /** + * AutomatedValidationTestOp constructor + */ + constructor() { + super(); + + this.name = "Automated Validation Test Op"; + this.module = "Default"; + this.description = "Operation used specifically to test automated parameter validation."; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + "name": "Integer Number", + "type": "number", + "value": 5, + "min": 5, + "max": 10, + "integer": true + }, + { + "name": "Real Number", + "type": "number", + "value": 1.5, + "min": 1.5, + "max": 5.5 + }, + { + "name": "Non Empty String", + "type": "string", + "value": "hello", + "maxLength": 5, + "allowEmpty": false + }, + { + "name": "Empty Allowed String", + "type": "string", + "value": "", + "allowEmpty": true + }, + { + "name": "Non Empty Toggle String", + "type": "toggleString", + "value": { + "option": "Option A", + "string": "test" + }, + "toggleValues": ["Option A", "Option B"], + "allowEmpty": false + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + return "Success"; + } + +} + +export default AutomatedValidationTestOp; diff --git a/src/core/operations/BLAKE3.mjs b/src/core/operations/BLAKE3.mjs index 53f7fdd6..a22eb0b8 100644 --- a/src/core/operations/BLAKE3.mjs +++ b/src/core/operations/BLAKE3.mjs @@ -30,7 +30,11 @@ class BLAKE3 extends Operation { this.args = [ { "name": "Size (bytes)", - "type": "number" + "type": "number", + "value": 16, + "min": 1, + "max": 65535, // arbitrary limit to prevent resource exhaustion + "integer": true, }, { "name": "Key", "type": "string", diff --git a/src/core/operations/BitShiftLeft.mjs b/src/core/operations/BitShiftLeft.mjs index cd9f4568..540ab659 100644 --- a/src/core/operations/BitShiftLeft.mjs +++ b/src/core/operations/BitShiftLeft.mjs @@ -27,7 +27,10 @@ class BitShiftLeft extends Operation { { "name": "Amount", "type": "number", - "value": 1 + "value": 1, + "min": 0, + "max": 7, + "integer": true, } ]; } diff --git a/src/core/operations/DechunkHTTPResponse.mjs b/src/core/operations/DechunkHTTPResponse.mjs index da2eb437..40b97c7a 100644 --- a/src/core/operations/DechunkHTTPResponse.mjs +++ b/src/core/operations/DechunkHTTPResponse.mjs @@ -45,12 +45,15 @@ class DechunkHTTPResponse extends Operation { const lineEndingsLength = lineEndings.length; let chunkSize = parseInt(input.slice(0, chunkSizeEnd), 16); while (!isNaN(chunkSize)) { + if (chunkSize === 0) { + break; + } chunks.push(input.slice(chunkSizeEnd, chunkSize + chunkSizeEnd)); input = input.slice(chunkSizeEnd + chunkSize + lineEndingsLength); chunkSizeEnd = input.indexOf(lineEndings) + lineEndingsLength; chunkSize = parseInt(input.slice(0, chunkSizeEnd), 16); } - return chunks.join("") + input; + return chunks.join(""); } } diff --git a/src/core/operations/FromBase.mjs b/src/core/operations/FromBase.mjs index 4abd5c44..8e69153b 100644 --- a/src/core/operations/FromBase.mjs +++ b/src/core/operations/FromBase.mjs @@ -51,9 +51,10 @@ class FromBase extends Operation { if (number.length === 1) return result; // Fractional part + const radixBN = new BigNumber(radix); for (let i = 0; i < number[1].length; i++) { const digit = new BigNumber(number[1][i], radix); - result += digit.div(Math.pow(radix, i+1)); + result = result.plus(digit.div(radixBN.pow(i + 1))); } return result; diff --git a/src/core/operations/Gzip.mjs b/src/core/operations/Gzip.mjs index 093ae6a4..43eaf091 100644 --- a/src/core/operations/Gzip.mjs +++ b/src/core/operations/Gzip.mjs @@ -74,13 +74,11 @@ class Gzip extends Operation { } if (comment.length) { options.flags.comment = true; + options.flags.fcomment = true; options.comment = comment; } const gzipObj = new Zlib.Gzip(new Uint8Array(input), options); const compressed = new Uint8Array(gzipObj.compress()); - if (options.flags.comment && !(compressed[3] & 0x10)) { - compressed[3] |= 0x10; - } return compressed.buffer; } diff --git a/src/core/operations/Jsonata.mjs b/src/core/operations/Jsonata.mjs index 82cc4d39..04259343 100644 --- a/src/core/operations/Jsonata.mjs +++ b/src/core/operations/Jsonata.mjs @@ -51,6 +51,18 @@ class JsonataQuery extends Operation { try { const expression = jsonata(query); + // Override built-in base64 functions which fail in Web Worker + // context where `window` is undefined. The jsonata library falls + // back to `global.Buffer` which also does not exist in workers. + // `atob`/`btoa` are available in both browser and worker scopes. + expression.registerFunction("base64decode", (str) => { + if (typeof str === "undefined") return undefined; + return atob(str); + }, ""); + expression.registerFunction("base64encode", (str) => { + if (typeof str === "undefined") return undefined; + return btoa(str); + }, ""); result = await expression.evaluate(jsonObj); } catch (err) { throw new OperationError( diff --git a/src/core/operations/MIMEDecoding.mjs b/src/core/operations/MIMEDecoding.mjs index 7b52fbdd..4ba04c18 100644 --- a/src/core/operations/MIMEDecoding.mjs +++ b/src/core/operations/MIMEDecoding.mjs @@ -87,7 +87,7 @@ class MIMEDecoding extends Operation { end = cur + j + "?=".length; if (encoding.toLowerCase() === "b") { - text = fromBase64(text); + text = fromBase64(text, undefined, "byteArray"); } else if (encoding.toLowerCase() === "q") { text = this.parseQEncodedWord(text); } else { diff --git a/src/core/operations/PseudoRandomNumberGenerator.mjs b/src/core/operations/PseudoRandomNumberGenerator.mjs index 53150566..da23c4de 100644 --- a/src/core/operations/PseudoRandomNumberGenerator.mjs +++ b/src/core/operations/PseudoRandomNumberGenerator.mjs @@ -31,7 +31,8 @@ class PseudoRandomNumberGenerator extends Operation { { "name": "Number of bytes", "type": "number", - "value": 32 + "value": 32, + "min": 1 }, { "name": "Output as", diff --git a/src/core/operations/RenderPDF.mjs b/src/core/operations/RenderPDF.mjs new file mode 100644 index 00000000..c1b6cfb5 --- /dev/null +++ b/src/core/operations/RenderPDF.mjs @@ -0,0 +1,100 @@ +/** + * @author Shailendra [singhshailendra.in] + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +import { fromBase64, toBase64 } from "../lib/Base64.mjs"; +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import Utils from "../Utils.mjs"; + +/** + * Render PDF operation + */ +class RenderPDF extends Operation { + + /** + * RenderPDF constructor + */ + constructor() { + super(); + + this.name = "Render PDF"; + this.module = "File"; + this.description = "Displays the input as a PDF preview. Supports Raw and Base64 input formats."; + this.inputType = "string"; + this.outputType = "byteArray"; + this.presentType = "html"; + this.args = [ + { + "name": "Input format", + "type": "option", + "value": ["Base64", "Raw"], + } + ]; + this.checks = [ + { + pattern: "^%PDF-", + flags: "", + args: ["Raw"], + useful: true, + output: { + mime: "application/pdf" + } + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {byteArray} + */ + run(input, args) { + const inputFormat = args[0]; + + if (!input.length) return []; + + // Convert input to raw bytes + switch (inputFormat) { + case "Base64": + input = fromBase64(input, undefined, "byteArray"); + break; + case "Raw": + default: + input = Utils.strToByteArray(input); + break; + } + + // Check PDF signature + if ( + input[0] !== 0x25 || // % + input[1] !== 0x50 || // P + input[2] !== 0x44 || // D + input[3] !== 0x46 // F + ) { + throw new OperationError("Input does not appear to be a PDF file."); + } + + return input; + } + + /** + * Displays the PDF using HTML for web apps. + * + * @param {byteArray} data + * @returns {html} + */ + async present(data) { + if (!data.length) return ""; + + const base64 = toBase64(data); + const dataURI = "data:application/pdf;base64," + base64; + + return ``; + } + +} + +export default RenderPDF; diff --git a/src/core/operations/SetDifference.mjs b/src/core/operations/SetDifference.mjs index dc46c079..d5ab92d3 100644 --- a/src/core/operations/SetDifference.mjs +++ b/src/core/operations/SetDifference.mjs @@ -75,9 +75,16 @@ class SetDifference extends Operation { * @returns {Object[]} */ runSetDifference(a, b) { + const excluded = new Set(b); + const seen = new Set(); + return a .filter((item) => { - return b.indexOf(item) === -1; + if (excluded.has(item) || seen.has(item)) { + return false; + } + seen.add(item); + return true; }) .join(this.itemDelimiter); } diff --git a/src/core/operations/SetIntersection.mjs b/src/core/operations/SetIntersection.mjs index 7e6dbe10..423fcd4f 100644 --- a/src/core/operations/SetIntersection.mjs +++ b/src/core/operations/SetIntersection.mjs @@ -75,9 +75,16 @@ class SetIntersection extends Operation { * @returns {Object[]} */ runIntersect(a, b) { + const included = new Set(b); + const seen = new Set(); + return a .filter((item) => { - return b.indexOf(item) > -1; + if (!included.has(item) || seen.has(item)) { + return false; + } + seen.add(item); + return true; }) .join(this.itemDelimiter); } diff --git a/src/core/operations/ShowOnMap.mjs b/src/core/operations/ShowOnMap.mjs index d75c2aa6..2eab5140 100644 --- a/src/core/operations/ShowOnMap.mjs +++ b/src/core/operations/ShowOnMap.mjs @@ -71,6 +71,16 @@ class ShowOnMap extends Operation { } latLong = latLong.replace(/[,]$/, ""); latLong = latLong.replace(/°/g, ""); + + // The map requires a latitude and longitude pair. If the conversion only produced a + // single value (e.g. because the chosen input delimiter didn't match the input), bail + // out with a helpful message rather than passing it on to the map, which would throw an + // uncaught TypeError in the browser. + const coords = latLong.split(",").map(v => v.trim()); + if (coords.length !== 2 || coords.some(v => v === "" || isNaN(Number(v)))) { + throw new OperationError(`Could not show coordinates '${latLong}' on the map. Expected a latitude and longitude pair - check that the input format and delimiter are correct.`); + } + return latLong; } return input; diff --git a/src/core/operations/ToBase.mjs b/src/core/operations/ToBase.mjs index 09a91571..4bf7ae83 100644 --- a/src/core/operations/ToBase.mjs +++ b/src/core/operations/ToBase.mjs @@ -28,7 +28,10 @@ class ToBase extends Operation { { "name": "Radix", "type": "number", - "value": 36 + "value": 36, + "min": 2, + "max": 36, + "integer": true, } ]; } @@ -43,9 +46,6 @@ class ToBase extends Operation { throw new OperationError("Error: Input must be a number"); } const radix = args[0]; - if (radix < 2 || radix > 36) { - throw new OperationError("Error: Radix argument must be between 2 and 36"); - } return input.toString(radix); } diff --git a/src/core/operations/ToBinary.mjs b/src/core/operations/ToBinary.mjs index ba72a55b..b19f94f0 100644 --- a/src/core/operations/ToBinary.mjs +++ b/src/core/operations/ToBinary.mjs @@ -35,7 +35,10 @@ class ToBinary extends Operation { { "name": "Byte Length", "type": "number", - "value": 8 + "value": 8, + "min": 1, + "max": 256, // arbitrary - significantly larger than word size for any known machine ("640k ought to be enough for anybody") + "integer": true } ]; } diff --git a/src/core/operations/URLEncode.mjs b/src/core/operations/URLEncode.mjs index a5efd213..99eec91d 100644 --- a/src/core/operations/URLEncode.mjs +++ b/src/core/operations/URLEncode.mjs @@ -21,7 +21,7 @@ class URLEncode extends Operation { this.module = "URL"; this.description = "Encodes problematic characters into percent-encoding, a format supported by URIs/URLs.

e.g. = becomes %3d"; this.infoURL = "https://wikipedia.org/wiki/Percent-encoding"; - this.inputType = "string"; + this.inputType = "byteArray"; this.outputType = "string"; this.args = [ { @@ -33,34 +33,38 @@ class URLEncode extends Operation { } /** - * @param {string} input + * @param {byteArray} input * @param {Object[]} args * @returns {string} */ run(input, args) { const encodeAll = args[0]; - return encodeAll ? this.encodeAllChars(input) : encodeURI(input); + return this.encodeBytes(input, encodeAll); } /** - * Encode characters in URL outside of encodeURI() function spec + * Encode bytes in URL using percent encoding. * - * @param {string} str + * @param {byteArray} bytes + * @param {boolean} encodeAll * @returns {string} */ - encodeAllChars (str) { - // TODO Do this programmatically - return encodeURIComponent(str) - .replace(/!/g, "%21") - .replace(/#/g, "%23") - .replace(/'/g, "%27") - .replace(/\(/g, "%28") - .replace(/\)/g, "%29") - .replace(/\*/g, "%2A") - .replace(/-/g, "%2D") - .replace(/\./g, "%2E") - .replace(/_/g, "%5F") - .replace(/~/g, "%7E"); + encodeBytes(bytes, encodeAll) { + const safeChars = encodeAll ? + /^[A-Za-z0-9]$/ : + /^[A-Za-z0-9:/?#[\]@!$&'()*+,;=%]$/; + + let output = ""; + + for (const byte of bytes) { + const char = String.fromCharCode(byte); + + output += safeChars.test(char) ? + char : + "%" + byte.toString(16).toUpperCase().padStart(2, "0"); + } + + return output; } } diff --git a/src/core/operations/UnescapeUnicodeCharacters.mjs b/src/core/operations/UnescapeUnicodeCharacters.mjs index 02d16662..f7759c78 100644 --- a/src/core/operations/UnescapeUnicodeCharacters.mjs +++ b/src/core/operations/UnescapeUnicodeCharacters.mjs @@ -56,7 +56,8 @@ class UnescapeUnicodeCharacters extends Operation { */ run(input, args) { const prefix = prefixToRegex[args[0]], - regex = new RegExp(prefix+"([a-f\\d]{4})", "ig"); + quantifier = args[0] === "U+" ? "{4,6}" : "{4}", + regex = new RegExp(prefix+"([a-f\\d]"+quantifier+")", "ig"); let output = "", m, i = 0; diff --git a/src/core/operations/XORBruteForce.mjs b/src/core/operations/XORBruteForce.mjs index 8c097731..96ea8ad0 100644 --- a/src/core/operations/XORBruteForce.mjs +++ b/src/core/operations/XORBruteForce.mjs @@ -31,7 +31,10 @@ class XORBruteForce extends Operation { { "name": "Key length", "type": "number", - "value": 1 + "value": 1, + "min": 1, + "max": 2, + "integer": true }, { "name": "Sample length", diff --git a/src/core/operations/XORChecksum.mjs b/src/core/operations/XORChecksum.mjs index 1603a265..338b4bea 100644 --- a/src/core/operations/XORChecksum.mjs +++ b/src/core/operations/XORChecksum.mjs @@ -7,12 +7,12 @@ import Operation from "../Operation.mjs"; import Utils from "../Utils.mjs"; import { toHex } from "../lib/Hex.mjs"; +import OperationError from "../errors/OperationError.mjs"; /** * XOR Checksum operation */ class XORChecksum extends Operation { - /** * XORChecksum constructor */ @@ -21,7 +21,8 @@ class XORChecksum extends Operation { this.name = "XOR Checksum"; this.module = "Crypto"; - this.description = "XOR Checksum splits the input into blocks of a configurable size and performs the XOR operation on these blocks."; + this.description = + "XOR Checksum splits the input into blocks of a configurable size and performs the XOR operation on these blocks."; this.infoURL = "https://wikipedia.org/wiki/XOR"; this.inputType = "ArrayBuffer"; this.outputType = "string"; @@ -29,7 +30,7 @@ class XORChecksum extends Operation { { name: "Blocksize", type: "number", - value: 4 + value: 4, }, ]; } @@ -41,6 +42,12 @@ class XORChecksum extends Operation { */ run(input, args) { const blocksize = args[0]; + + + if (!Number.isInteger(blocksize) || blocksize <= 0) { + throw new OperationError("Blocksize must be a positive integer."); + } + input = new Uint8Array(input); const res = Array(blocksize); diff --git a/src/node/api.mjs b/src/node/api.mjs index 8002a8ac..f41feb23 100644 --- a/src/node/api.mjs +++ b/src/node/api.mjs @@ -193,6 +193,8 @@ export function _wrap(OpClass) { wrapped = async (input, args=null) => { const {transformedInput, transformedArgs} = prepareOp(opInstance, input, args); + opInstance.validateIngredients(transformedArgs); + // SPECIAL CASE for Magic. Other flowControl operations will // not work because the opList is not passed in. if (isFlowControl) { @@ -229,6 +231,7 @@ export function _wrap(OpClass) { */ wrapped = (input, args=null) => { const {transformedInput, transformedArgs} = prepareOp(opInstance, input, args); + opInstance.validateIngredients(transformedArgs); const result = opInstance.run(transformedInput, transformedArgs); return new NodeDish({ value: result, diff --git a/src/web/HTMLOperation.mjs b/src/web/HTMLOperation.mjs index 06ed2d3d..8e89fe4f 100755 --- a/src/web/HTMLOperation.mjs +++ b/src/web/HTMLOperation.mjs @@ -58,9 +58,10 @@ class HTMLOperation { if (this.description) { const infoLink = this.infoURL ? `
${titleFromWikiLink(this.infoURL)}` : ""; + const content = Utils.escapeHtml(this.description + infoLink); html += ` data-container='body' data-toggle='popover' data-placement='right' - data-content="${this.description}${infoLink}" data-html='true' data-trigger='hover' + data-content="${content}" data-html='true' data-trigger='hover' data-boundary='viewport' role='button'`; } diff --git a/src/web/stylesheets/layout/_io.css b/src/web/stylesheets/layout/_io.css index 0146bf27..abca9d84 100755 --- a/src/web/stylesheets/layout/_io.css +++ b/src/web/stylesheets/layout/_io.css @@ -24,6 +24,14 @@ height: 100%; user-select: auto; } + +#output-html > img { + display: block; + max-width: 100%; + max-height: 100%; + margin: auto; +} + #output-text.html-output .cm-line .cm-widgetBuffer, #output-text.html-output .cm-line>br { display: none; diff --git a/tests/browser/00_nightwatch.js b/tests/browser/00_nightwatch.js index e64b476b..a0f093ee 100644 --- a/tests/browser/00_nightwatch.js +++ b/tests/browser/00_nightwatch.js @@ -56,6 +56,32 @@ module.exports = { browser.expect.element("//li[contains(@class, 'operation') and text()='Play Media']").to.be.present; browser.expect.element("//li[contains(@class, 'operation') and text()='Disassemble x86']").to.be.present; browser.expect.element("//li[contains(@class, 'operation') and text()='Register']").to.be.present; + browser.expect.element("//li[contains(@class, 'operation') and text()='Escape Smart Characters']").to.be.present; + }, + + "Operation popover descriptions render HTML safely": browser => { + const favouritesCat = "//a[contains(@class, 'category-title') and contains(@data-target, '#catFavourites')]", + op = "//ul[@id='search-results']//li[contains(@class, 'operation') and contains(., 'Escape Smart Characters')]"; + + browser + .useCss() + .clearValue("#search") + .setValue("#search", "Escape Smart Characters") + .useXpath() + .waitForElementVisible(op, 1000) + .moveToElement(op, 10, 10) + .useCss() + .waitForElementVisible(".popover-body code:last-of-type", 1000) + .expect.element(".popover-body code:last-of-type").text.to.contain("\"Hello\" -- world..."); + + browser + .useCss() + .moveToElement("#operations .title", 1, 1) + .waitForElementNotPresent(".popover-body", 1000) + .clearValue("#search") + .useXpath() + .getLocationInView(favouritesCat) + .click(favouritesCat); }, "Recipe can be run": browser => { diff --git a/tests/browser/02_ops.js b/tests/browser/02_ops.js index e295f08c..bb1a5e78 100644 --- a/tests/browser/02_ops.js +++ b/tests/browser/02_ops.js @@ -218,6 +218,7 @@ module.exports = { testOpHtml(browser, "JSON Beautify", "{a:1}", ".json-dict .json-literal", "1"); // testOp(browser, "JSON Minify", "test input", "test_output"); // testOp(browser, "JSON to CSV", "test input", "test_output"); + testOp(browser, "Jsonata Query", '{"a": "SGVsbG8gV29ybGQh"}', '"Hello World!"', ["$base64decode($.a)"]); // testOp(browser, "JWT Decode", "test input", "test_output"); // testOp(browser, "JWT Sign", "test input", "test_output"); // testOp(browser, "JWT Verify", "test input", "test_output"); @@ -492,7 +493,22 @@ function testOpImage(browser, opName, filename, args=[]) { browser .waitForElementVisible("#output-html img") - .expect.element("#output-html img").to.have.css("width").which.matches(/^[^0]\d*px/); + .expect.element("#output-html img").to.have.css("width").which.matches(/^(?!0+(?:\.0+)?px$)\d+(?:\.\d+)?px$/); + + browser.execute(function() { + const output = document.getElementById("output-html"); + const img = output.querySelector("img"); + const outputRect = output.getBoundingClientRect(); + const imgRect = img.getBoundingClientRect(); + + return { + imageFitsWidth: imgRect.width <= outputRect.width, + imageFitsHeight: imgRect.height <= outputRect.height, + }; + }, [], function({value}) { + browser.expect(value.imageFitsWidth).to.be.equal(true); + browser.expect(value.imageFitsHeight).to.be.equal(true); + }); } /** @function diff --git a/tests/node/index.mjs b/tests/node/index.mjs index 52670d48..360bf481 100644 --- a/tests/node/index.mjs +++ b/tests/node/index.mjs @@ -25,6 +25,7 @@ import "./tests/NodeDish.mjs"; import "./tests/Utils.mjs"; import "./tests/Categories.mjs"; import "./tests/lib/BigIntUtils.mjs"; +import "./tests/lib/ChartsProtocolPrototypePollution.mjs"; const testStatus = { allTestsPassing: true, diff --git a/tests/node/tests/lib/ChartsProtocolPrototypePollution.mjs b/tests/node/tests/lib/ChartsProtocolPrototypePollution.mjs new file mode 100644 index 00000000..be4e7667 --- /dev/null +++ b/tests/node/tests/lib/ChartsProtocolPrototypePollution.mjs @@ -0,0 +1,90 @@ +import TestRegister from "../../../lib/TestRegister.mjs"; +import {getSeriesValues} from "../../../../src/core/lib/Charts.mjs"; +import {objToTable} from "../../../../src/core/lib/Protocol.mjs"; +import SeriesChart from "../../../../src/core/operations/SeriesChart.mjs"; +import ParseUDP from "../../../../src/core/operations/ParseUDP.mjs"; +import it from "../../assertionHandler.mjs"; +import assert from "assert"; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); + +TestRegister.addApiTests([ + it("Charts: should not pollute Object.prototype from a __proto__ series name", () => { + const xVal = ""; + delete Object.prototype[xVal]; + + try { + const result = getSeriesValues(`__proto__,${xVal},1`, "\n", ",", false); + + assert.equal(Object.prototype[xVal], undefined); + assert.deepEqual(result.xValues, [xVal]); + assert.equal(result.series.length, 1); + assert.equal(result.series[0].name, "__proto__"); + assert.equal(Object.getPrototypeOf(result.series[0].data), null); + assert(hasOwn(result.series[0].data, xVal)); + assert.equal(result.series[0].data[xVal], 1); + } finally { + delete Object.prototype[xVal]; + } + }), + + it("Charts: should keep __proto__ x-axis names as own data keys", () => { + const result = getSeriesValues("safe,__proto__,1", "\n", ",", false); + + assert.equal(result.series.length, 1); + assert.equal(Object.getPrototypeOf(result.series[0].data), null); + assert(hasOwn(result.series[0].data, "__proto__")); + assert.equal(result.series[0].data.__proto__, 1); + }), + + it("Protocol: should ignore inherited properties when rendering tables", () => { + const inheritedKey = ""; + delete Object.prototype[inheritedKey]; + + try { + Object.prototype[inheritedKey] = "polluted"; + + const html = objToTable({safe: "value"}); + + assert(!html.includes(inheritedKey)); + assert(!html.includes("polluted")); + assert(html.includes("safe")); + assert(html.includes("value")); + } finally { + delete Object.prototype[inheritedKey]; + } + }), + + it("Protocol: should escape table keys and scalar values", () => { + const obj = { + "field": "", + }; + + const html = objToTable(obj); + + assert(!html.includes("field")); + assert(!html.includes("")); + assert(html.includes("<b>field</b>")); + assert(html.includes("<img src=x onerror=alert(1)>")); + }), + + it("Series chart and Parse UDP: should not expose polluted prototype data as HTML", () => { + const xVal = ""; + delete Object.prototype[xVal]; + + try { + const chartHtml = new SeriesChart().run( + `__proto__,${xVal},1`, + ["Line feed", "Comma", "", 1, "red"] + ); + assert.equal(Object.prototype[xVal], undefined); + + const parseUDP = new ParseUDP(); + const tableHtml = parseUDP.present(parseUDP.run(chartHtml, ["Raw"])); + + assert(!/\nTo: =?ISO-8859-1?Q?Keld_J=F8rn_Simonsen?= \nCC: =?ISO-8859-1?Q?Andr=E9?= Pirard \nSubject: =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\n=?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=", diff --git a/tests/operations/tests/Median.mjs b/tests/operations/tests/Median.mjs new file mode 100644 index 00000000..555f2edd --- /dev/null +++ b/tests/operations/tests/Median.mjs @@ -0,0 +1,33 @@ +/** + * Median operation tests. + * + * @author copilot-swe-agent[bot] + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "Median: odd-length input", + input: "10 1 2", + expectedOutput: "2", + recipeConfig: [ + { + op: "Median", + args: ["Space"], + }, + ], + }, + { + name: "Median: even-length input", + input: "10 1 2 5", + expectedOutput: "3.5", + recipeConfig: [ + { + op: "Median", + args: ["Space"], + }, + ], + }, +]); diff --git a/tests/operations/tests/ParseTLV.mjs b/tests/operations/tests/ParseTLV.mjs index 5c99eee2..9033848d 100644 --- a/tests/operations/tests/ParseTLV.mjs +++ b/tests/operations/tests/ParseTLV.mjs @@ -52,5 +52,46 @@ TestRegister.addTests([ "args": [1, 4, true] // length value is patently wrong, should be ignored by BER. } ] + }, + { + name: "Parse TLV: BER long-form length (two-byte length encoding)", + input: "\x01\x82\x01\x00" + "A".repeat(256) + "\x02\x03\x41\x42\x43", + expectedOutput: JSON.stringify([ + {"key": [1], "length": 256, "value": Array(256).fill(65)}, + {"key": [2], "length": 3, "value": [65, 66, 67]} + ], null, 4), + recipeConfig: [ + { + "op": "Parse TLV", + "args": [1, 1, true] + } + ] + }, + { + name: "Parse TLV: BER long-form length (one-byte length encoding)", + input: "\x01\x81\x80" + "B".repeat(128), + expectedOutput: JSON.stringify([ + {"key": [1], "length": 128, "value": Array(128).fill(66)} + ], null, 4), + recipeConfig: [ + { + "op": "Parse TLV", + "args": [1, 1, true] + } + ] + }, + { + name: "Parse TLV: BER multiple entries with mixed short and long-form lengths", + input: "\x01\x05\x48\x65\x6c\x6c\x6f\x02\x81\x05\x57\x6f\x72\x6c\x64", + expectedOutput: JSON.stringify([ + {"key": [1], "length": 5, "value": [72, 101, 108, 108, 111]}, + {"key": [2], "length": 5, "value": [87, 111, 114, 108, 100]} + ], null, 4), + recipeConfig: [ + { + "op": "Parse TLV", + "args": [1, 1, true] + } + ] } ]); diff --git a/tests/operations/tests/RenderPDF.mjs b/tests/operations/tests/RenderPDF.mjs new file mode 100644 index 00000000..f359aa20 --- /dev/null +++ b/tests/operations/tests/RenderPDF.mjs @@ -0,0 +1,37 @@ +/** + * RenderPDF tests. + * + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + + +TestRegister.addTests([ + { + name: "RenderPDF", + input: "Not a PDF", + expectedOutput: "Input does not appear to be a PDF file.", + recipeConfig: [ + { + op: "Render PDF", + args: ["Raw"] + }, + ], + }, + { + name: "RenderPDF", + input: "", + expectedMatch: /^