diff --git a/package-lock.json b/package-lock.json index 63b3f13a..7fc0a2ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1612,45 +1612,45 @@ } }, "node_modules/@jitl/quickjs-ffi-types": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.31.0.tgz", - "integrity": "sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", "license": "MIT" }, "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.31.0.tgz", - "integrity": "sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/@jitl/quickjs-wasmfile-debug-sync": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.31.0.tgz", - "integrity": "sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.31.0.tgz", - "integrity": "sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/@jitl/quickjs-wasmfile-release-sync": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.31.0.tgz", - "integrity": "sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -2783,6 +2783,13 @@ "react-dom": ">=16.8" } }, + "node_modules/@types/atob": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/atob/-/atob-2.1.4.tgz", + "integrity": "sha512-FisOhG87cCFqzCgq6FUtSYsTMOHCB/p28zJbSN1QBo4ZGJfg9PEhMjdIV++NDeOnloUUe0Gz6jwBV+L1Ac00Mw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2839,6 +2846,16 @@ "@types/node": "*" } }, + "node_modules/@types/btoa": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/btoa/-/btoa-1.2.5.tgz", + "integrity": "sha512-BItINdjZRlcGdI2efwK4bwxY5vEAT0SnIVfMOZVT18wp4900F1Lurqk/9PNdF9hMP1zgFmWbjVEtAsQKVcbqxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2849,6 +2866,13 @@ "@types/node": "*" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3011,6 +3035,13 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, + "node_modules/@types/path-browserify": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/path-browserify/-/path-browserify-1.0.3.tgz", + "integrity": "sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -3090,6 +3121,13 @@ "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", "license": "MIT" }, + "node_modules/@types/tv4": { + "version": "1.2.33", + "resolved": "https://registry.npmjs.org/@types/tv4/-/tv4-1.2.33.tgz", + "integrity": "sha512-7phCVTXC6Bj50IV1iKOwqGkR4JONJyMbRZnKTSuujv1S/tO9rG5OdCt7BMSjytO+zJmYdn1/I4fd3SH0gtO99g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3807,6 +3845,18 @@ "node": ">= 0.4" } }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/autoprefixer": { "version": "10.5.4", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", @@ -4077,6 +4127,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -4550,6 +4612,13 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -8615,6 +8684,15 @@ "license": "MIT", "optional": true }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/monaco-editor": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", @@ -9123,6 +9201,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9517,28 +9601,28 @@ "license": "MIT" }, "node_modules/quickjs-emscripten": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.31.0.tgz", - "integrity": "sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", "license": "MIT", "dependencies": { - "@jitl/quickjs-wasmfile-debug-asyncify": "0.31.0", - "@jitl/quickjs-wasmfile-debug-sync": "0.31.0", - "@jitl/quickjs-wasmfile-release-asyncify": "0.31.0", - "@jitl/quickjs-wasmfile-release-sync": "0.31.0", - "quickjs-emscripten-core": "0.31.0" + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" }, "engines": { "node": ">=16.0.0" } }, "node_modules/quickjs-emscripten-core": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.31.0.tgz", - "integrity": "sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/railroad-diagrams": { @@ -11224,6 +11308,24 @@ "node": "*" } }, + "node_modules/tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "license": [ + { + "type": "Public Domain", + "url": "http://geraintluff.github.io/tv4/LICENSE.txt" + }, + { + "type": "MIT", + "url": "http://jsonary.com/LICENSE.txt" + } + ], + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11574,6 +11676,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -13144,9 +13260,13 @@ "@types/markdown-it": "^14.1.2", "@types/prismjs": "^1.26.5", "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "atob": "^2.1.2", + "btoa": "^1.2.1", "buffer": "^6.0.3", "chai": "~5.3.3", "codemirror": "^6.0.2", + "crypto-js": "^4.2.0", "fast-json-format": "~0.4.0", "fuse.js": "^7.5.0", "js-md5": "^0.9.2", @@ -13155,12 +13275,14 @@ "jsonpath-plus": "^10.3.0", "lodash-es": "~4.17.21", "markdown-it": "^14.1.0", + "moment": "^2.30.1", "monaco-editor": "^0.53.0", "nanoid": "~3.3.11", "node-html-parser": "^8.0.4", + "path-browserify": "^1.0.1", "prettier": "^2.7.1", "prismjs": "^1.29.0", - "quickjs-emscripten": "~0.31.0", + "quickjs-emscripten": "~0.32.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-markdown": "^10.0.0", @@ -13170,6 +13292,8 @@ "react-router-dom": "^7.3.0", "remark-gfm": "^4.0.1", "strip-json-comments": "^3.1.1", + "tv4": "^1.3.0", + "uuid": "^10.0.0", "xml-formatter": "^3.5.0" }, "devDependencies": { @@ -13179,12 +13303,17 @@ "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.13", "@tailwindcss/typography": "^0.5.10", + "@types/atob": "^2.1.4", + "@types/btoa": "^1.2.5", + "@types/crypto-js": "^4.2.2", "@types/express": "^4.17.21", "@types/lodash-es": "~4.17.12", "@types/node": "^26.2.0", + "@types/path-browserify": "^1.0.3", "@types/prismjs": "^1.26.3", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/tv4": "^1.2.33", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", @@ -13202,6 +13331,23 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } + }, + "packages/bruno-api-docs/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } } } } diff --git a/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts new file mode 100644 index 00000000..b435b366 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from '../../playwright'; +import type { Page } from '@playwright/test'; +import type { CodeEditorComponent } from '../../components/code-editor/code-editor.component'; + +const LIBRARY_TESTS_SCRIPT = ` +const moment = require('moment'); +const CryptoJS = require('crypto-js'); +const { v4, validate } = require('uuid'); +const { nanoid } = require('nanoid'); +const jwt = require('jsonwebtoken'); + +test('moment formats a date', function () { + expect(moment('2026-01-02').format('YYYY-MM-DD')).to.equal('2026-01-02'); +}); + +test('crypto-js hashes and uuid validates', function () { + expect(CryptoJS.SHA256('abc').toString()).to.have.lengthOf(64); + expect(validate(v4())).to.equal(true); + expect(nanoid(10)).to.have.lengthOf(10); +}); + +test('jsonwebtoken round-trips a signed token', function () { + const token = jwt.sign({ userId: 7 }, 'secret', { expiresIn: '1h' }); + expect(jwt.verify(token, 'secret').userId).to.equal(7); +}); +`; + +const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise => { + await editor.focus(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.insertText(script); +}; + +test.describe('playground script execution', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test('runs a tests script using the safe-mode libraries on Send', async ({ page, playground, responsePane }) => { + await page.route('**/api/users**', (route) => + route.fulfill({ + status: 200, + headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' }, + body: JSON.stringify({ users: [{ id: 1, name: 'Ada' }] }) + }) + ); + + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + + await playground.selectTab('tests'); + await setEditorScript(page, playground.testsEditor, LIBRARY_TESTS_SCRIPT); + + await responsePane.send(); + await responsePane.switchToTab('tests'); + + await expect(page.getByText(/Passed: [1-9]\d*, Failed: 0/).first()).toBeVisible(); + await expect(page.getByText(/Failed: [1-9]/)).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/package.json b/packages/bruno-api-docs/package.json index 15d514c0..2a82215c 100644 --- a/packages/bruno-api-docs/package.json +++ b/packages/bruno-api-docs/package.json @@ -73,9 +73,13 @@ "@types/markdown-it": "^14.1.2", "@types/prismjs": "^1.26.5", "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "atob": "^2.1.2", + "btoa": "^1.2.1", "buffer": "^6.0.3", "chai": "~5.3.3", "codemirror": "^6.0.2", + "crypto-js": "^4.2.0", "fast-json-format": "~0.4.0", "fuse.js": "^7.5.0", "js-md5": "^0.9.2", @@ -84,12 +88,14 @@ "jsonpath-plus": "^10.3.0", "lodash-es": "~4.17.21", "markdown-it": "^14.1.0", + "moment": "^2.30.1", "monaco-editor": "^0.53.0", "nanoid": "~3.3.11", "node-html-parser": "^8.0.4", + "path-browserify": "^1.0.1", "prettier": "^2.7.1", "prismjs": "^1.29.0", - "quickjs-emscripten": "~0.31.0", + "quickjs-emscripten": "~0.32.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-markdown": "^10.0.0", @@ -99,6 +105,8 @@ "react-router-dom": "^7.3.0", "remark-gfm": "^4.0.1", "strip-json-comments": "^3.1.1", + "tv4": "^1.3.0", + "uuid": "^10.0.0", "xml-formatter": "^3.5.0" }, "devDependencies": { @@ -108,12 +116,17 @@ "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.13", "@tailwindcss/typography": "^0.5.10", + "@types/atob": "^2.1.4", + "@types/btoa": "^1.2.5", + "@types/crypto-js": "^4.2.2", "@types/express": "^4.17.21", "@types/lodash-es": "~4.17.12", "@types/node": "^26.2.0", + "@types/path-browserify": "^1.0.3", "@types/prismjs": "^1.26.3", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/tv4": "^1.2.33", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx index 86ffe1f8..21070112 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; import type { Item } from '@opencollection/types/collection/item'; -import { requestRunner } from '@/runner'; import { getAncestorsByUuid } from '@/utils/fileUtils'; import { ItemVariableResolverProvider } from '@/hooks'; import TitleLabel from '@/components/TitleLabel/TitleLabel'; @@ -35,7 +34,6 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec // The request/response split is one draggable divider whose axis follows the // orientation: horizontal layout resizes width, vertical layout resizes height. const { size: paneSize, isResizing, containerRef, startResize } = useSplitPane(orientation); - const runner = useMemo(() => requestRunner, []); const ancestry = useMemo( () => (collection && itemUuid ? getAncestorsByUuid(collection, itemUuid) : []), [collection, itemUuid] @@ -87,7 +85,8 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec const environment = envs.find( (env: any) => env.name === selectedEnvironment ); - const result = await runner.runRequest({ + const { requestRunner } = await import('@/runner'); + const result = await requestRunner.runRequest({ item: editableItem, collection, environment, @@ -112,7 +111,7 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec } finally { setIsLoading(false); } - }, [collection, editableItem, runner, selectedEnvironment, itemUuid]); + }, [collection, editableItem, selectedEnvironment, itemUuid]); return ( { expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/data?api_key=secret123'); }); + describe('auth header precedence', () => { + const jsonResponse = () => ({ + status: 200, + statusText: 'OK', + url: 'https://api.example.com/data', + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => JSON.stringify({ ok: true }) + }); + + it('keeps an existing Authorization header over the bearer auth config', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse()); + global.fetch = fetchMock as unknown as typeof fetch; + + await new RequestExecutor().executeRequest({ + name: 'script-set auth wins', + type: 'http', + http: { + method: 'GET', + url: 'https://api.example.com/data', + headers: [{ name: 'authorization', value: 'Bearer script-signed-token' }], + auth: { type: 'bearer', token: 'config-token' } + } + } as any); + + expect(fetchMock.mock.calls[0][1].headers['authorization']).toBe('Bearer script-signed-token'); + expect(fetchMock.mock.calls[0][1].headers['Authorization']).toBeUndefined(); + }); + + it('applies the bearer auth config when no Authorization header exists', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse()); + global.fetch = fetchMock as unknown as typeof fetch; + + await new RequestExecutor().executeRequest({ + name: 'auth config applies', + type: 'http', + http: { + method: 'GET', + url: 'https://api.example.com/data', + auth: { type: 'bearer', token: 'config-token' } + } + } as any); + + expect(fetchMock.mock.calls[0][1].headers['Authorization']).toBe('Bearer config-token'); + }); + + it('keeps an existing header over an apikey header placement', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse()); + global.fetch = fetchMock as unknown as typeof fetch; + + await new RequestExecutor().executeRequest({ + name: 'apikey header respects existing', + type: 'http', + http: { + method: 'GET', + url: 'https://api.example.com/data', + headers: [{ name: 'X-Api-Key', value: 'from-script' }], + auth: { type: 'apikey', key: 'x-api-key', value: 'from-config', placement: 'header' } + } + } as any); + + expect(fetchMock.mock.calls[0][1].headers['X-Api-Key']).toBe('from-script'); + expect(fetchMock.mock.calls[0][1].headers['x-api-key']).toBeUndefined(); + }); + }); + describe('request body', () => { const sendWithBody = async (method: string) => { const fetchMock = vi.fn().mockResolvedValue({ diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.ts index e3e24f88..86332eed 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.ts @@ -191,21 +191,24 @@ export class RequestExecutor { } private setAuthHeaders(headers: Record, auth: any) { + const hasHeader = (name: string) => + Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase()); + switch (auth.type) { case 'basic': - if (auth.username && auth.password) { + if (auth.username && auth.password && !hasHeader('Authorization')) { const credentials = btoa(`${auth.username}:${auth.password}`); headers['Authorization'] = `Basic ${credentials}`; } break; case 'bearer': - if (auth.token) { + if (auth.token && !hasHeader('Authorization')) { headers['Authorization'] = `Bearer ${auth.token}`; } break; case 'apikey': if (auth.key && auth.value) { - if (auth.placement === 'header') { + if (auth.placement === 'header' && !hasHeader(auth.key)) { headers[auth.key] = auth.value; } } diff --git a/packages/bruno-api-docs/src/sampleCollection.ts b/packages/bruno-api-docs/src/sampleCollection.ts index 7a484f16..28b13e5a 100644 --- a/packages/bruno-api-docs/src/sampleCollection.ts +++ b/packages/bruno-api-docs/src/sampleCollection.ts @@ -81,7 +81,9 @@ request: bru.setVar('collection-var-set-by-collection-script', 'collection-var-value-set-by-collection-script'); } - type: after-response - code: wefewfewfewfewfwefwefewfewfewfewfewfewfewfewf + code: |- + // Collection ยท post-response (L0) + console.log('POST > L0 collection'); - type: tests code: |- // used by \`scripting/js/folder-collection script-tests\` diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts index 00b696e9..637f8b26 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts @@ -1,6 +1,44 @@ import { expect, assert } from 'chai'; -// todo: add all the supported libraries +import { Buffer } from 'buffer'; +import moment from 'moment'; +import btoa from 'btoa'; +import atob from 'atob'; +import CryptoJS from 'crypto-js'; +import tv4 from 'tv4'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import * as uuid from 'uuid'; +import * as nanoid from 'nanoid'; +import path from 'path-browserify'; +import jwt from './lib/jwt'; + +(globalThis as any).expect = expect; +(globalThis as any).assert = assert; +(globalThis as any).moment = moment; +(globalThis as any).btoa = btoa; +(globalThis as any).atob = atob; +(globalThis as any).Buffer = Buffer; +(globalThis as any).tv4 = tv4; +(globalThis as any).Ajv = Ajv; +(globalThis as any).addFormats = addFormats; +(globalThis as any).uuid = uuid; +(globalThis as any).nanoid = nanoid; +(globalThis as any).path = path; +(globalThis as any).jwt = jwt; + (globalThis as any).requireObject = { ...((globalThis as any).requireObject || {}), - chai: { expect, assert } + 'chai': { expect, assert }, + 'moment': moment, + 'buffer': { Buffer }, + 'btoa': btoa, + 'atob': atob, + 'crypto-js': CryptoJS, + 'tv4': tv4, + 'ajv': Ajv, + 'ajv-formats': addFormats, + 'uuid': uuid, + 'nanoid': nanoid, + 'path': path, + 'jsonwebtoken': jwt }; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts index f40604a8..79365e7d 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts @@ -3,9 +3,12 @@ import addBrunoRequestShimToContext from './shims/bruno-request'; import addConsoleShimToContext from './shims/console'; import addBrunoResponseShimToContext from './shims/bruno-response'; import addTestShimToContext from './shims/test'; +import addCryptoUtilsShimToContext from './shims/lib/crypto-utils'; +import addAxiosShimToContext from './shims/lib/axios'; import { newQuickJSWASMModule, memoizePromiseFactory } from 'quickjs-emscripten'; import { marshallToVm } from './utils'; import { getBundledCode } from './bundled-libraries.iife.js'; +import { getRequireCode } from './shims/require'; let QuickJSSyncContext: any; const loader = memoizePromiseFactory(() => newQuickJSWASMModule()); @@ -95,51 +98,28 @@ const executeQuickJsVmAsync = async ({ externalScript = externalScript?.trim(); try { - const module = await newQuickJSWASMModule(); + const module = await loader(); const vm = module.newContext(); - const bundledCode = getBundledCode?.toString() || ''; - - const moduleLoaderCode = function () { - return ` - globalThis.require = (mod) => { - let lib = globalThis.requireObject[mod]; - let isModuleAPath = (module) => (module?.startsWith('.') || module?.startsWith?.('')) - if (lib) { - return lib; - } - else if (isModuleAPath(mod)) { - // fetch local module - let localModuleCode = globalThis.__brunoLoadLocalModule(mod); - - // compile local module as iife - (function (){ - const initModuleExportsCode = "const module = { exports: {} };" - const copyModuleExportsCode = "\\n;globalThis.requireObject[mod] = module.exports;"; - const patchedRequire = ${` - "\\n;" + - "let require = (subModule) => isModuleAPath(subModule) ? globalThis.require(path.resolve('', mod, '..', subModule)) : globalThis.require(subModule)" + - "\\n;" - `} - eval(initModuleExportsCode + patchedRequire + localModuleCode + copyModuleExportsCode); - })(); - - // resolve module - return globalThis.requireObject[mod]; - } - else { - throw new Error("Cannot find module " + mod); - } - } - `; - }; + addCryptoUtilsShimToContext(vm); + + if (typeof getBundledCode !== 'function') { + throw new Error('Sandbox library bundle is missing; run build:lib-bundle before executing scripts.'); + } + const bundledCode = getBundledCode.toString(); - vm.evalCode( + const bootResult = vm.evalCode( ` (${bundledCode})(); - ${moduleLoaderCode()} + ${getRequireCode()} ` ); + if (bootResult.error) { + const bootError = vm.dump(bootResult.error); + bootResult.error.dispose(); + throw new Error(`Failed to load sandbox libraries: ${bootError?.message || String(bootError)}`); + } + bootResult.value.dispose(); const { bru, req, res, test, __brunoTestResults, console: consoleFn } = externalContext; @@ -149,6 +129,8 @@ const executeQuickJsVmAsync = async ({ if (res) addBrunoResponseShimToContext(vm, res); if (test && __brunoTestResults) addTestShimToContext(vm, __brunoTestResults); + addAxiosShimToContext(vm); + const script = ` (async () => { const setTimeout = async(fn, timer) => { diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/lib/jwt.spec.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/lib/jwt.spec.ts new file mode 100644 index 00000000..ffb43ab8 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/lib/jwt.spec.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import jwt from './jwt'; + +const SECRET = 'test-secret'; + +describe('jwt (sandbox jsonwebtoken replacement)', () => { + it('signs and verifies a payload with each HS algorithm', () => { + for (const algorithm of ['HS256', 'HS384', 'HS512']) { + const token = jwt.sign({ userId: 42 }, SECRET, { algorithm, noTimestamp: true }); + expect(jwt.verify(token, SECRET)).toEqual({ userId: 42 }); + } + }); + + it('produces the exact token bytes for a fixed payload', () => { + const token = jwt.sign({ userId: 1, iat: 1700000000 }, 'shared-secret'); + expect(token).toBe( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTcwMDAwMDAwMH0.sFf3AhH_8eWxHEXov2j3nvjgg4XgTSD4Mc4Ao3saMhs' + ); + }); + + it('rejects a tampered token with the real library message', () => { + const token = jwt.sign({ a: 1 }, SECRET, { noTimestamp: true }) as string; + const tampered = token.slice(0, -4) + 'AAAA'; + expect(() => jwt.verify(tampered, SECRET)).toThrowError('invalid signature'); + }); + + it('expires tokens and honours clockTimestamp', () => { + const token = jwt.sign({ a: 1, iat: 1700000000 }, SECRET, { expiresIn: '1h' }); + expect(jwt.verify(token, SECRET, { clockTimestamp: 1700000000 + 3599 }).a).toBe(1); + expect(() => jwt.verify(token, SECRET, { clockTimestamp: 1700000000 + 3601 })).toThrowError('jwt expired'); + }); + + it('honours notBefore with the real library message', () => { + const token = jwt.sign({ a: 1, iat: 1700000000 }, SECRET, { notBefore: '10m' }); + expect(() => jwt.verify(token, SECRET, { clockTimestamp: 1700000000 + 60 })).toThrowError('jwt not active'); + expect(jwt.verify(token, SECRET, { clockTimestamp: 1700000000 + 601 }).a).toBe(1); + }); + + it('parses expiresIn duration strings like the ms grammar', () => { + const token = jwt.sign({ a: 1, iat: 1700000000 }, SECRET, { expiresIn: '2 days' }); + expect((jwt.decode(token) as any).exp).toBe(1700000000 + 2 * 24 * 60 * 60); + }); + + it('validates audience, issuer and maxAge with real library messages', () => { + const token = jwt.sign({ a: 1, aud: ['web', 'mobile'], iss: 'bruno', iat: 1700000000 }, SECRET); + expect(jwt.verify(token, SECRET, { audience: 'mobile', clockTimestamp: 1700000000 }).iss).toBe('bruno'); + expect(() => jwt.verify(token, SECRET, { audience: 'desktop', clockTimestamp: 1700000000 })) + .toThrowError('jwt audience invalid. expected: desktop'); + expect(() => jwt.verify(token, SECRET, { issuer: 'other', clockTimestamp: 1700000000 })) + .toThrowError('jwt issuer invalid. expected: other'); + expect(() => jwt.verify(token, SECRET, { maxAge: '30m', clockTimestamp: 1700000000 + 3600 })) + .toThrowError('maxAge exceeded'); + }); + + it('decodes without verification, including complete mode and malformed input', () => { + const token = jwt.sign({ a: 1 }, SECRET, { noTimestamp: true }); + expect(jwt.decode(token)).toEqual({ a: 1 }); + expect((jwt.decode(token, { complete: true }) as any).header).toEqual({ alg: 'HS256', typ: 'JWT' }); + expect(jwt.decode('not-a-token')).toBeNull(); + }); + + it('supports non-string secrets via byte arrays', () => { + const secretBytes = [112, 97, 115, 115]; + const token = jwt.sign({ a: 1 }, secretBytes, { noTimestamp: true }); + expect(jwt.verify(token, 'pass')).toEqual({ a: 1 }); + }); + + it('supports the node callback form', () => { + let signError: any = 'not-called'; + let signedToken: string | undefined; + jwt.sign({ b: 2 }, SECRET, { noTimestamp: true }, (error: any, token: string) => { + signError = error; + signedToken = token; + }); + expect(signError).toBeNull(); + expect(typeof signedToken).toBe('string'); + + let verifyError: any = 'not-called'; + let decodedPayload: any; + jwt.verify(signedToken as string, SECRET, (error: any, decoded: any) => { + verifyError = error; + decodedPayload = decoded; + }); + expect(verifyError).toBeNull(); + expect(decodedPayload).toEqual({ b: 2 }); + }); + + it('rejects the alg=none attack token and asymmetric algorithms', () => { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ a: 1 })).toString('base64url'); + expect(() => jwt.verify(`${header}.${payload}.`, SECRET)).toThrowError('jwt signature is required'); + expect(() => jwt.sign({ a: 1 }, 'key', { algorithm: 'RS256' })) + .toThrowError(/RS256 is not supported in the docs playground/); + }); + + it('throws when verify is called without a secret', () => { + const token = jwt.sign({ a: 1 }, SECRET, { noTimestamp: true }); + expect(() => jwt.verify(token, undefined as any)).toThrowError('secret or public key must be provided'); + }); + + it('rejects an asymmetric key handed to an HMAC algorithm (RS/HS confusion)', () => { + const pem = '-----BEGIN PUBLIC KEY-----\nMFkw\n-----END PUBLIC KEY-----'; + expect(() => jwt.sign({ a: 1 }, pem)).toThrowError('an asymmetric key was provided for an HMAC (HS) algorithm'); + const token = jwt.sign({ a: 1 }, SECRET, { noTimestamp: true }); + expect(() => jwt.verify(token, pem)).toThrowError('an asymmetric key was provided for an HMAC (HS) algorithm'); + }); + + it('signs a string payload verbatim (no JSON quoting)', () => { + const token = jwt.sign('hello', SECRET); + expect(jwt.decode(token)).toBe('hello'); + expect(jwt.verify(token, SECRET)).toBe('hello'); + }); + + it('omits typ for a string payload and keeps it for an object payload', () => { + const stringHeader = jwt.decode(jwt.sign('hello', SECRET), { complete: true }).header; + expect(stringHeader.typ).toBeUndefined(); + expect(stringHeader.alg).toBe('HS256'); + + const objectHeader = jwt.decode(jwt.sign({ a: 1 }, SECRET), { complete: true }).header; + expect(objectHeader.typ).toBe('JWT'); + }); + + it('rejects claim options for a string payload with the real library messages', () => { + for (const option of ['expiresIn', 'notBefore', 'noTimestamp', 'audience', 'issuer', 'subject', 'jwtid']) { + expect(() => jwt.sign('hello', SECRET, { [option]: option === 'noTimestamp' ? true : '1h' })) + .toThrowError(`invalid ${option} option for string payload`); + } + }); + + it('invokes a throwing callback at most once', () => { + let calls = 0; + const throwingCallback = () => { + calls += 1; + throw new Error('callback boom'); + }; + expect(() => jwt.sign({ a: 1 }, SECRET, { noTimestamp: true }, throwingCallback)).toThrowError('callback boom'); + expect(calls).toBe(1); + }); + + it('produces HS256/384/512 signatures matching node crypto (interop)', async () => { + const { createHmac } = await import('node:crypto'); + const algs: Array<['HS256' | 'HS384' | 'HS512', string]> = [ + ['HS256', 'sha256'], ['HS384', 'sha384'], ['HS512', 'sha512'] + ]; + for (const [alg, nodeAlg] of algs) { + const token = jwt.sign({ userId: 7 }, SECRET, { algorithm: alg, noTimestamp: true }) as string; + const [header, payload, signature] = token.split('.'); + const reference = createHmac(nodeAlg, SECRET).update(`${header}.${payload}`).digest('base64url'); + expect(signature).toBe(reference); + } + }); + + it('signs a string-payload token a strict jws verifier accepts (interop)', async () => { + const { createHmac } = await import('node:crypto'); + const token = jwt.sign('raw-string', SECRET) as string; + const [header, payload, signature] = token.split('.'); + const reference = createHmac('sha256', SECRET).update(`${header}.${payload}`).digest('base64url'); + expect(signature).toBe(reference); + expect(JSON.parse(Buffer.from(header, 'base64url').toString()).typ).toBeUndefined(); + expect(Buffer.from(payload, 'base64url').toString()).toBe('raw-string'); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/lib/jwt.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/lib/jwt.ts new file mode 100644 index 00000000..e668a5ee --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/lib/jwt.ts @@ -0,0 +1,313 @@ +import CryptoJS from 'crypto-js'; + +type HmacFn = (message: string, secret: CryptoJS.lib.WordArray | string) => CryptoJS.lib.WordArray; + +const SUPPORTED_ALGORITHMS: Record = { + HS256: (message, secret) => CryptoJS.HmacSHA256(message, secret), + HS384: (message, secret) => CryptoJS.HmacSHA384(message, secret), + HS512: (message, secret) => CryptoJS.HmacSHA512(message, secret) +}; + +const ASYMMETRIC_ALGORITHM_PATTERN = /^(RS|ES|PS)\d{3}$/; + +const unsupportedAlgorithmMessage = (algorithm: string) => + `${algorithm} is not supported in the docs playground; HS256, HS384 and HS512 are available. ` + + 'Asymmetric algorithms need a private key, which should never be embedded in a published collection script.'; + +const DURATION_UNIT_MS: Record = { + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + y: 365.25 * 24 * 60 * 60 * 1000 +}; + +const DURATION_PATTERN + = /^(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i; + +const normalizeDurationUnit = (rawUnit: string): string => { + const unit = rawUnit.toLowerCase(); + if (unit.startsWith('ms') || unit.startsWith('mil')) return 'ms'; + return unit[0]; +}; + +const parseDurationToSeconds = (value: number | string, optionName: string): number => { + if (typeof value === 'number') { + return value; + } + const match = DURATION_PATTERN.exec(String(value).trim()); + if (!match) { + throw new Error(`"${optionName}" should be a number of seconds or string representing a timespan`); + } + const amount = parseFloat(match[1]); + const unitKey = normalizeDurationUnit(match[2] || 'ms'); + return Math.floor((amount * DURATION_UNIT_MS[unitKey]) / 1000); +}; + +const toHmacSecret = (secret: any): CryptoJS.lib.WordArray | string => { + if (typeof secret === 'string') { + return secret; + } + if (secret && typeof secret === 'object' && typeof secret.length === 'number') { + const bytes = Array.from(secret as ArrayLike); + const words: number[] = []; + // crypto-js stores bytes packed four-per-word, most significant byte first. + for (let i = 0; i < bytes.length; i++) { + words[i >>> 2] = (words[i >>> 2] || 0) | (bytes[i] << (24 - (i % 4) * 8)); + } + return CryptoJS.lib.WordArray.create(words, bytes.length); + } + return String(secret); +}; + +const base64UrlFromWordArray = (words: CryptoJS.lib.WordArray): string => { + return words.toString(CryptoJS.enc.Base64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const base64UrlEncodeString = (value: string): string => { + return base64UrlFromWordArray(CryptoJS.enc.Utf8.parse(value)); +}; + +const base64UrlEncodeJson = (value: unknown): string => { + return base64UrlEncodeString(JSON.stringify(value)); +}; + +const assertSymmetricSecret = (secret: unknown) => { + if (typeof secret === 'string' && secret.includes('-----BEGIN')) { + throw new Error('an asymmetric key was provided for an HMAC (HS) algorithm'); + } +}; + +const base64UrlDecodeToString = (segment: string): string => { + let base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4 !== 0) { + base64 += '='; + } + return CryptoJS.enc.Base64.parse(base64).toString(CryptoJS.enc.Utf8); +}; + +const constantTimeEquals = (a: string, b: string): boolean => { + if (a.length !== b.length) { + return false; + } + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +}; + +const nowInSeconds = () => Math.floor(Date.now() / 1000); + +const resolveCallback = (options: any, callback: any) => { + if (typeof options === 'function') { + return { resolvedOptions: undefined, resolvedCallback: options }; + } + return { resolvedOptions: options, resolvedCallback: callback }; +}; + +const withOptionalCallback = (callback: any, run: () => T): T | undefined => { + if (typeof callback !== 'function') { + return run(); + } + let result: T; + try { + result = run(); + } catch (error) { + callback(error, undefined); + return undefined; + } + callback(null, result); + return undefined; +}; + +const matchesAudience = (audienceClaim: any, expected: any): boolean => { + const claims: any[] = Array.isArray(audienceClaim) ? audienceClaim : [audienceClaim]; + const expectations: any[] = Array.isArray(expected) ? expected : [expected]; + return expectations.some((expectation) => + claims.some((claim) => + expectation instanceof RegExp ? expectation.test(String(claim)) : expectation === claim + ) + ); +}; + +const signSync = (payload: any, secret: any, options: any): string => { + const algorithm = options?.algorithm || 'HS256'; + const hmac = SUPPORTED_ALGORITHMS[algorithm]; + if (!hmac) { + throw new Error( + ASYMMETRIC_ALGORITHM_PATTERN.test(algorithm) ? unsupportedAlgorithmMessage(algorithm) : 'invalid algorithm' + ); + } + if (secret === undefined || secret === null || secret === '') { + throw new Error('secretOrPrivateKey must have a value'); + } + assertSymmetricSecret(secret); + + const isObjectPayload = typeof payload === 'object' && payload !== null; + + if (!isObjectPayload) { + const invalidOption = ['expiresIn', 'notBefore', 'noTimestamp', 'audience', 'issuer', 'subject', 'jwtid'] + .find((name) => options?.[name] !== undefined); + if (invalidOption) { + throw new Error(`invalid ${invalidOption} option for string payload`); + } + } + + const claims: any = isObjectPayload && options?.mutatePayload !== true ? { ...payload } : payload; + + if (isObjectPayload) { + if (options?.noTimestamp !== true && claims.iat === undefined) { + claims.iat = nowInSeconds(); + } + if (options?.expiresIn !== undefined) { + claims.exp = (claims.iat ?? nowInSeconds()) + parseDurationToSeconds(options.expiresIn, 'expiresIn'); + } + if (options?.notBefore !== undefined) { + claims.nbf = (claims.iat ?? nowInSeconds()) + parseDurationToSeconds(options.notBefore, 'notBefore'); + } + if (options?.audience !== undefined) claims.aud = options.audience; + if (options?.issuer !== undefined) claims.iss = options.issuer; + if (options?.subject !== undefined) claims.sub = options.subject; + if (options?.jwtid !== undefined) claims.jti = options.jwtid; + } + + const header = isObjectPayload + ? { alg: algorithm, typ: 'JWT', ...(options?.header || {}) } + : { alg: algorithm, ...(options?.header || {}) }; + const encodedPayload = typeof claims === 'string' ? base64UrlEncodeString(claims) : base64UrlEncodeJson(claims); + const signingInput = `${base64UrlEncodeJson(header)}.${encodedPayload}`; + const signature = base64UrlFromWordArray(hmac(signingInput, toHmacSecret(secret))); + return `${signingInput}.${signature}`; +}; + +const decode = (token: any, options?: any): any => { + if (typeof token !== 'string') { + return null; + } + const segments = token.split('.'); + if (segments.length !== 3) { + return null; + } + try { + const header = JSON.parse(base64UrlDecodeToString(segments[0])); + const payloadText = base64UrlDecodeToString(segments[1]); + let payload: any; + try { + payload = JSON.parse(payloadText); + } catch { + if (options?.json) { + return null; + } + payload = payloadText; + } + if (options?.complete) { + return { header, payload, signature: segments[2] }; + } + return payload; + } catch { + return null; + } +}; + +const verifySync = (token: any, secret: any, options: any): any => { + if (!token) { + throw new Error('jwt must be provided'); + } + if (typeof token !== 'string' || token.split('.').length !== 3) { + throw new Error('jwt malformed'); + } + if (secret === undefined || secret === null || secret === '') { + throw new Error('secret or public key must be provided'); + } + assertSymmetricSecret(secret); + + const [headerSegment, payloadSegment, signatureSegment] = token.split('.'); + if (signatureSegment === '') { + throw new Error('jwt signature is required'); + } + + const decoded = decode(token, { complete: true }); + if (!decoded) { + throw new Error('jwt malformed'); + } + + const algorithm = decoded.header?.alg; + const hmac = SUPPORTED_ALGORITHMS[algorithm]; + if (!hmac) { + throw new Error( + ASYMMETRIC_ALGORITHM_PATTERN.test(String(algorithm)) ? unsupportedAlgorithmMessage(String(algorithm)) : 'invalid algorithm' + ); + } + if (options?.algorithms && !options.algorithms.includes(algorithm)) { + throw new Error('invalid algorithm'); + } + + const expectedSignature = base64UrlFromWordArray(hmac(`${headerSegment}.${payloadSegment}`, toHmacSecret(secret))); + if (!constantTimeEquals(expectedSignature, signatureSegment)) { + throw new Error('invalid signature'); + } + + const payload = decoded.payload; + const clockTimestamp = options?.clockTimestamp ?? nowInSeconds(); + const clockTolerance = options?.clockTolerance ?? 0; + + if (typeof payload === 'object' && payload !== null) { + const notBeforeApplies = payload.nbf !== undefined && options?.ignoreNotBefore !== true; + if (notBeforeApplies && payload.nbf > clockTimestamp + clockTolerance) { + throw new Error('jwt not active'); + } + const expiryApplies = payload.exp !== undefined && options?.ignoreExpiration !== true; + if (expiryApplies && payload.exp <= clockTimestamp - clockTolerance) { + throw new Error('jwt expired'); + } + if (options?.audience !== undefined) { + if (payload.aud === undefined || !matchesAudience(payload.aud, options.audience)) { + throw new Error(`jwt audience invalid. expected: ${[].concat(options.audience).join(' or ')}`); + } + } + if (options?.issuer !== undefined) { + const issuers = Array.isArray(options.issuer) ? options.issuer : [options.issuer]; + if (!issuers.includes(payload.iss)) { + throw new Error(`jwt issuer invalid. expected: ${issuers.join(',')}`); + } + } + if (options?.subject !== undefined && payload.sub !== options.subject) { + throw new Error(`jwt subject invalid. expected: ${options.subject}`); + } + if (options?.jwtid !== undefined && payload.jti !== options.jwtid) { + throw new Error(`jwt jwtid invalid. expected: ${options.jwtid}`); + } + if (options?.nonce !== undefined && payload.nonce !== options.nonce) { + throw new Error('jwt nonce invalid. expected: ' + options.nonce); + } + if (options?.maxAge !== undefined) { + if (payload.iat === undefined) { + throw new Error('iat required when maxAge is specified'); + } + const maxAgeSeconds = parseDurationToSeconds(options.maxAge, 'maxAge'); + if (payload.iat + maxAgeSeconds <= clockTimestamp - clockTolerance) { + throw new Error('maxAge exceeded'); + } + } + } + + if (options?.complete) { + return decoded; + } + return payload; +}; + +const sign = (payload: any, secret: any, options?: any, callback?: any) => { + const { resolvedOptions, resolvedCallback } = resolveCallback(options, callback); + return withOptionalCallback(resolvedCallback, () => signSync(payload, secret, resolvedOptions)); +}; + +const verify = (token: any, secret: any, options?: any, callback?: any) => { + const { resolvedOptions, resolvedCallback } = resolveCallback(options, callback); + return withOptionalCallback(resolvedCallback, () => verifySync(token, secret, resolvedOptions)); +}; + +export default { sign, verify, decode }; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts new file mode 100644 index 00000000..2d6a74ee --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { newQuickJSWASMModule } from 'quickjs-emscripten'; +import addCryptoUtilsShimToContext from './shims/lib/crypto-utils'; +import addAxiosShimToContext from './shims/lib/axios'; +import { getRequireCode } from './shims/require'; +import { getBundledCode } from './bundled-libraries.iife.js'; + +const DESKTOP_SAFE_MODE_MODULES = [ + 'ajv', 'ajv-formats', 'atob', 'axios', 'btoa', 'buffer', 'chai', 'crypto-js', + 'jsonwebtoken', 'moment', 'nanoid', 'path', 'tv4', 'uuid' +]; + +let vm: any; + +const inVm = (expression: string) => { + const result = vm.evalCode(expression); + if (result.error) { + const error = vm.dump(result.error); + result.error.dispose(); + throw new Error(error.message); + } + const value = vm.dump(result.value); + result.value.dispose(); + return value; +}; + +const errorMessageOf = (expression: string) => + inVm(`(() => { try { ${expression}; return 'NO-THROW'; } catch (e) { return e.message; } })()`); + +describe('sandbox library parity with desktop safe mode', () => { + beforeAll(async () => { + const module = await newQuickJSWASMModule(); + vm = module.newContext(); + addCryptoUtilsShimToContext(vm); + const boot = vm.evalCode( + `(${getBundledCode.toString()})(); ${getRequireCode()}; ` + + `globalThis.console = { log() {}, debug() {}, info() {}, warn() {}, error() {} };` + ); + expect(boot.error).toBeUndefined(); + boot.value.dispose(); + addAxiosShimToContext(vm); + }); + + it('exposes exactly the 14 desktop safe-mode modules', () => { + expect(inVm('Object.keys(globalThis.requireObject).sort()')).toEqual(DESKTOP_SAFE_MODE_MODULES); + }); + + it('exposes the desktop safe-mode globals', () => { + const globals = ['expect', 'assert', 'moment', 'btoa', 'atob', 'Buffer', 'tv4', 'Ajv', 'addFormats', 'crypto', 'axios', 'jwt', 'path', 'require']; + for (const name of globals) { + expect(inVm(`typeof globalThis['${name}']`), name).not.toBe('undefined'); + } + }); + + it('every module does real work inside the VM', () => { + expect(inVm(`require('moment')('2026-08-20T10:00:00Z').utc().format('YYYY-MM-DD')`)).toBe('2026-08-20'); + expect(inVm(`require('crypto-js').SHA256('abc').toString()`)).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(inVm(`require('uuid').validate(require('uuid').v4())`)).toBe(true); + expect(inVm(`require('nanoid').nanoid(10).length`)).toBe(10); + expect(inVm(`require('buffer').Buffer.from('hello').toString('base64')`)).toBe('aGVsbG8='); + expect(inVm(`require('btoa')('hello')`)).toBe('aGVsbG8='); + expect(inVm(`require('atob')('aGVsbG8=')`)).toBe('hello'); + expect(inVm(`require('tv4').validate({ a: 1 }, { type: 'object' })`)).toBe(true); + expect(inVm(`new (require('ajv'))().compile({ type: 'number' })(5)`)).toBe(true); + expect(inVm(`(() => { const Ajv = require('ajv'); const ajv = new Ajv(); require('ajv-formats')(ajv); return ajv.compile({ type: 'string', format: 'email' })('a@b.co'); })()`)).toBe(true); + expect(inVm(`require('path').resolve('/a/b', '../c')`)).toBe('/a/c'); + expect(inVm(`(() => { const { expect } = require('chai'); expect(1).to.eql(1); return 'ok'; })()`)).toBe('ok'); + expect(inVm(`(() => { const jwtLib = require('jsonwebtoken'); return jwtLib.verify(jwtLib.sign({ u: 1 }, 's', { noTimestamp: true }), 's').u; })()`)).toBe(1); + expect(inVm(`typeof require('axios').get`)).toBe('function'); + }); + + it('gives explanatory errors for developer-mode-only and node builtin modules', () => { + expect(errorMessageOf(`require('lodash')`)).toContain('only available in the Bruno desktop app\'s developer mode'); + expect(errorMessageOf(`require('fs')`)).toContain('is a Node.js builtin'); + expect(errorMessageOf(`require('node:fs')`)).toContain('is a Node.js builtin'); + expect(errorMessageOf(`require('./helper.js')`)).toContain('Local file require'); + expect(errorMessageOf(`require('./helper.js')`)).toContain('is not available in the docs playground'); + expect(inVm(`typeof require('node:buffer').Buffer`)).toBe('function'); + expect(inVm(`typeof require('node:path').resolve`)).toBe('function'); + expect(errorMessageOf(`require('node:chai')`)).toContain('Cannot find module node:chai'); + expect(errorMessageOf(`require('left-pad-9000')`)).toBe('Cannot find module left-pad-9000'); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.ts new file mode 100644 index 00000000..e773a2e2 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.ts @@ -0,0 +1,204 @@ +import { cleanJson } from '@/scripting/utils/common'; +import { marshallToVm } from '../../utils'; + +const METHODS = ['get', 'post', 'put', 'delete', 'patch']; +const METHODS_WITH_BODY = new Set(['post', 'put', 'patch']); +const UNSUPPORTED_METHODS = ['head', 'options', 'request', 'create', 'all']; + +interface AxiosShimConfig { + url?: string; + method?: string; + headers?: Record; + params?: Record; + data?: any; + timeout?: number; + auth?: { username?: string; password?: string }; +} + +const normalizeArgs = (method: string | null, args: any[]): AxiosShimConfig => { + if (method === null) { + if (typeof args[0] === 'string') { + return { ...(args[1] || {}), url: args[0], method: (args[1]?.method || 'get') }; + } + return { method: 'get', ...(args[0] || {}) }; + } + + if (METHODS_WITH_BODY.has(method)) { + return { ...(args[2] || {}), url: args[0], data: args[1], method }; + } + return { ...(args[1] || {}), url: args[0], method }; +}; + +const buildRequestUrl = (config: AxiosShimConfig): string => { + const url = config.url || ''; + if (!config.params || typeof config.params !== 'object') { + return url; + } + + const query = new URLSearchParams(); + Object.entries(config.params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + query.append(key, String(value)); + } + }); + const queryString = query.toString(); + if (!queryString) { + return url; + } + return url + (url.includes('?') ? '&' : '?') + queryString; +}; + +const buildAxiosError = (partial: Record, config: AxiosShimConfig) => { + return { + isAxiosError: true, + ...partial, + config: { + url: config.url, + method: config.method, + headers: config.headers, + data: config.data + } + }; +}; + +const parseResponseData = async (response: Response) => { + const contentType = response.headers.get('content-type') || ''; + const text = await response.text(); + if (contentType.includes('json') && text.length) { + try { + return JSON.parse(text); + } catch { + return text; + } + } + return text; +}; + +const performAxiosRequest = async (config: AxiosShimConfig) => { + if (typeof config.url !== 'string' || !/^https?:\/\//i.test(config.url.trim())) { + throw buildAxiosError( + { message: `Only absolute http(s) URLs are supported here, received: ${String(config.url)}`, code: 'ERR_INVALID_URL' }, + config + ); + } + + const headers: Record = { ...(config.headers || {}) }; + + if (config.auth?.username !== undefined) { + headers['Authorization'] = 'Basic ' + btoa(`${config.auth.username || ''}:${config.auth.password || ''}`); + } + + let body: string | undefined; + if (config.data !== undefined && config.data !== null) { + if (typeof config.data === 'object') { + body = JSON.stringify(config.data); + const hasContentType = Object.keys(headers).some((key) => key.toLowerCase() === 'content-type'); + if (!hasContentType) { + headers['Content-Type'] = 'application/json'; + } + } else { + body = String(config.data); + } + } + + const fetchOptions: RequestInit = { + method: (config.method || 'get').toUpperCase(), + headers, + // Never send the docs site's own cookies, so a published script cannot make + // authenticated calls to the host origin on the reader's behalf. + credentials: 'omit', + ...(body !== undefined && { body }), + ...(config.timeout && { signal: AbortSignal.timeout(config.timeout) }) + }; + + let response: Response; + try { + response = await fetch(buildRequestUrl(config), fetchOptions); + } catch (err: any) { + if (err?.name === 'TimeoutError') { + throw buildAxiosError({ message: `timeout of ${config.timeout}ms exceeded`, code: 'ECONNABORTED' }, config); + } + throw buildAxiosError( + { + message: 'Network Error', + code: 'ERR_NETWORK', + cause: `${err?.name || 'Error'}: ${err?.message || String(err)}`, + hint: 'The browser blocked or failed the request. If the API works in this Bruno app, the server may not allow cross-origin (CORS) requests from this docs site.' + }, + config + ); + } + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + const data = await parseResponseData(response); + + if (response.status < 200 || response.status >= 300) { + throw buildAxiosError( + { + message: `Request failed with status code ${response.status}`, + code: response.status >= 500 ? 'ERR_BAD_RESPONSE' : 'ERR_BAD_REQUEST', + response: { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data + } + }, + config + ); + } + + return { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data, + config: { url: config.url, method: config.method, headers: config.headers, data: config.data } + }; +}; + +const addAxiosShimToContext = (vm: any) => { + const registerAxiosFunction = (name: string, method: string | null) => { + const fnHandle = vm.newFunction(name, (...args: any[]) => { + const nativeArgs = args.map(vm.dump); + const config = normalizeArgs(method, nativeArgs); + const promise = vm.newPromise(); + performAxiosRequest(config) + .then((response) => { + promise.resolve(marshallToVm(cleanJson(response), vm)); + }) + .catch((err) => { + promise.reject(marshallToVm(cleanJson(err), vm)); + }); + promise.settled.then(vm.runtime.executePendingJobs); + return promise.handle; + }); + fnHandle.consume((handle: any) => vm.setProp(vm.global, name, handle)); + }; + + registerAxiosFunction('__bruno__axios', null); + METHODS.forEach((method) => registerAxiosFunction(`__bruno__axios__${method}`, method)); + + const bootResult = vm.evalCode(` + globalThis.axios = __bruno__axios; + ${METHODS.map((method) => `globalThis.axios.${method} = __bruno__axios__${method};`).join('\n')} + ${UNSUPPORTED_METHODS.map((method) => `globalThis.axios.${method} = () => { + throw new Error("axios.${method} is not supported in the docs playground; use axios(config) or axios.get/post/put/patch/delete."); + };`).join('\n')} + globalThis.requireObject = { + ...globalThis.requireObject, + axios: globalThis.axios, + }; + `); + if (bootResult.error) { + const error = vm.dump(bootResult.error); + bootResult.error.dispose(); + throw new Error(`Failed to install axios shim: ${error?.message || String(error)}`); + } + bootResult.value.dispose(); +}; + +export default addAxiosShimToContext; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/crypto-utils.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/crypto-utils.ts new file mode 100644 index 00000000..a75da936 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/crypto-utils.ts @@ -0,0 +1,92 @@ +import { marshallToVm } from '../../utils'; +import { serializeTypedArray, deserializeTypedArray } from './utils'; + +const MAX_RANDOM_BYTES_PER_CALL = 65536; + +const addCryptoUtilsShimToContext = (vm: any) => { + const randomBytesHandle = vm.newFunction('randomBytes', (sizeHandle: any) => { + try { + let size = vm.dump(sizeHandle); + if (typeof size !== 'number') { + throw new TypeError('The "size" argument must be of type number'); + } + + size = Math.trunc(size); + + if (size < 0) { + throw new RangeError('The "size" argument must be >= 0'); + } + + if (size > MAX_RANDOM_BYTES_PER_CALL) { + throw new RangeError('The "size" argument is too large'); + } + + if (size === 0) { + return marshallToVm([], vm); + } + + const bytes = new Uint8Array(size); + globalThis.crypto.getRandomValues(bytes); + + return marshallToVm(Array.from(bytes), vm); + } catch (error: any) { + const vmError = vm.newError(error.message); + vm.setProp(vmError, 'name', vm.newString(error.name)); + throw vmError; + } + }); + + const getRandomValuesHandle = vm.newFunction('getRandomValues', (arrayHandle: any) => { + try { + const serializedArray = vm.dump(arrayHandle); + const typedArray = deserializeTypedArray(serializedArray); + + if (typedArray.length === 0) { + return marshallToVm([], vm); + } + + if (typedArray.length > MAX_RANDOM_BYTES_PER_CALL) { + throw new Error('getRandomValues: ArrayBufferView byte length exceeds 65536'); + } + + globalThis.crypto.getRandomValues(typedArray); + + return marshallToVm(Array.from(typedArray), vm); + } catch (error: any) { + const vmError = vm.newError(error.message); + vm.setProp(vmError, 'name', vm.newString(error.name)); + throw vmError; + } + }); + + vm.setProp(vm.global, '__bruno__crypto__randomBytes', randomBytesHandle); + vm.setProp(vm.global, '__bruno__crypto__getRandomValues', getRandomValuesHandle); + randomBytesHandle.dispose(); + getRandomValuesHandle.dispose(); + + const bootResult = vm.evalCode(` + const serializeTypedArray = ${serializeTypedArray.toString()}; + + const cryptoModule = { + randomBytes: function(size) { + const byteArray = globalThis.__bruno__crypto__randomBytes(size); + return Buffer.from(Array.from(byteArray)); + }, + getRandomValues: function(typedArray) { + const serializedTypedArray = serializeTypedArray(typedArray); + typedArray.set(globalThis.__bruno__crypto__getRandomValues(serializedTypedArray)); + return typedArray; + }, + }; + + globalThis.crypto = cryptoModule; + `); + if (bootResult.error) { + const error = vm.dump(bootResult.error); + bootResult.error.dispose(); + throw new Error(`Failed to install crypto shim: ${error?.message || String(error)}`); + } + bootResult.value.dispose(); +}; + +export default addCryptoUtilsShimToContext; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.ts new file mode 100644 index 00000000..9d24b74f --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.ts @@ -0,0 +1,45 @@ +const ALLOWED_TYPED_ARRAYS = new Set([ + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array' +]); + +// Stringified and injected into the sandbox, so it must not reference anything outside its own body. +function serializeTypedArray(typedArray: any) { + return { + type: typedArray.constructor.name, + array: Array.from(typedArray), + length: typedArray.length + }; +} + +function deserializeTypedArray(obj: any) { + if (!obj || typeof obj !== 'object') { + throw new TypeError('getRandomValues: Invalid typed array object'); + } + + if (typeof obj.type !== 'string' || !ALLOWED_TYPED_ARRAYS.has(obj.type)) { + throw new TypeError(`getRandomValues: Invalid or unsupported typed array type: ${obj.type}`); + } + + if (!obj.array || typeof obj.length !== 'number') { + throw new TypeError('getRandomValues: Invalid typed array properties'); + } + + const TypedArrayConstructor = (globalThis as any)[obj.type]; + if (typeof TypedArrayConstructor !== 'function') { + throw new TypeError(`getRandomValues: Constructor ${obj.type} is not available`); + } + + return new TypedArrayConstructor(obj.array, 0, obj.length); +} + +export { serializeTypedArray, deserializeTypedArray }; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/require.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/require.ts new file mode 100644 index 00000000..f66d1db5 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/require.ts @@ -0,0 +1,47 @@ +const NODE_BUILTIN_MODULES = [ + 'assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns', 'events', + 'fs', 'http', 'http2', 'https', 'net', 'os', 'path', 'perf_hooks', 'process', 'querystring', + 'readline', 'stream', 'string_decoder', 'timers', 'tls', 'url', 'util', 'v8', 'vm', + 'worker_threads', 'zlib' +]; + +const DEVELOPER_MODE_LIBRARIES = [ + 'lodash', 'cheerio', 'xml2js', 'node-fetch', 'yaml', 'json-query', + 'xml-formatter', 'chai-string', 'handlebars' +]; + +export const getRequireCode = () => ` + globalThis.require = (mod) => { + const lib = globalThis.requireObject[mod]; + if (lib) { + return lib; + } + + if (mod?.startsWith?.('.') || mod?.startsWith?.('/')) { + throw new Error( + "Local file require ('" + mod + "') is not available in the docs playground; " + + 'only the built-in safe-mode libraries can be required here.' + ); + } + + const bareName = mod?.startsWith?.('node:') ? mod.slice(5) : mod; + const nodeBuiltins = ${JSON.stringify(NODE_BUILTIN_MODULES)}; + if (nodeBuiltins.includes(bareName)) { + if (globalThis.requireObject[bareName]) { + return globalThis.requireObject[bareName]; + } + throw new Error( + "'" + mod + "' is a Node.js builtin and is not available in the docs playground; " + + "it requires the Bruno desktop app's developer mode." + ); + } + if (${JSON.stringify(DEVELOPER_MODE_LIBRARIES)}.includes(bareName)) { + const available = Object.keys(globalThis.requireObject).sort().join(', '); + throw new Error( + "'" + mod + "' is only available in the Bruno desktop app's developer mode; " + + "the docs playground supports the safe-mode library set (" + available + ")." + ); + } + throw new Error("Cannot find module " + mod); + } +`; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.spec.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.spec.ts new file mode 100644 index 00000000..b38bc935 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { newQuickJSWASMModule } from 'quickjs-emscripten'; +import { marshallToVm } from './index'; + +let vm: any; + +const dump = (handle: any) => { + const value = vm.dump(handle); + handle.dispose(); + return value; +}; + +describe('marshallToVm', () => { + beforeAll(async () => { + const module = await newQuickJSWASMModule(); + vm = module.newContext(); + }); + + it('marshals primitives, arrays and nested objects', () => { + expect(dump(marshallToVm('x', vm))).toBe('x'); + expect(dump(marshallToVm(42, vm))).toBe(42); + expect(dump(marshallToVm(true, vm))).toBe(true); + expect(dump(marshallToVm([1, 'a', { b: 2 }], vm))).toEqual([1, 'a', { b: 2 }]); + }); + + it('does not carry a hostile __proto__ key onto the sandbox object', () => { + const hostile = JSON.parse('{"__proto__": {"polluted": true}, "safe": 1}'); + const handle = marshallToVm(hostile, vm); + vm.setProp(vm.global, 'marshalled', handle); + handle.dispose(); + + expect(dump(vm.evalCode('marshalled.safe').value)).toBe(1); + expect(dump(vm.evalCode('marshalled.polluted').value)).toBeUndefined(); + expect(dump(vm.evalCode('({}).polluted').value)).toBeUndefined(); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.ts index a587615d..aa7d8489 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.ts @@ -21,6 +21,9 @@ const marshallToVm = (value: any, vm: any) => { } else { const obj = vm.newObject(); for (const key in value) { + if (key === '__proto__') { + continue; + } vm.setProp(obj, key, marshallToVm(value[key], vm)); } return obj; diff --git a/packages/bruno-api-docs/vite.config.libraries-bundle.ts b/packages/bruno-api-docs/vite.config.libraries-bundle.ts index f2fcbeb3..e5618b24 100644 --- a/packages/bruno-api-docs/vite.config.libraries-bundle.ts +++ b/packages/bruno-api-docs/vite.config.libraries-bundle.ts @@ -24,7 +24,8 @@ export default defineConfig({ resolve: { alias: { '@slices': resolve(__dirname, 'src/store/slices'), - '@': resolve(__dirname, 'src') + '@': resolve(__dirname, 'src'), + 'atob': 'atob/node-atob.js' } }, build: {