diff --git a/lib/middleware/body-parser.js b/lib/middleware/body-parser.js index 01d23c5..8bb7587 100644 --- a/lib/middleware/body-parser.js +++ b/lib/middleware/body-parser.js @@ -14,6 +14,15 @@ const parseContentType = (contentTypeHeader) => { }; }; +/** + * Custom body parser middleware for Express. + * + * This middleware collects the raw request body data and parses it based on the Content-Type header. + * It supports JSON, URL-encoded, and multipart form data (including file uploads). + * The parsed data is attached to the request object as req.jsonBody, req.formBody, or req.multiPartSimple. + * It also generates HAR (HTTP Archive) objects for logging and debugging purposes. + * After parsing, it calls next() to pass control to the next middleware or route handler. + */ module.exports = (req, res, next) => { req.bodyChunks = []; diff --git a/lib/routes/bins/run.js b/lib/routes/bins/run.js index 8e01e9b..1a1732f 100644 --- a/lib/routes/bins/run.js +++ b/lib/routes/bins/run.js @@ -1,4 +1,6 @@ const debug = require("debug")("mockbin"); +const { faker } = require("@faker-js/faker"); +const { Liquid, Drop } = require("liquidjs"); module.exports = (client) => (req, res, next) => { // compoundId allows us to provide paths in the id to resolve to a specific bin @@ -42,13 +44,297 @@ module.exports = (client) => (req, res, next) => { res.location(har.redirectURL); } - return res.send(har.content.text ? har.content.text : null); + let body = har.content.text || null; + if (body) { + try { + body = renderBody({ + template: body, + req, + }); + } catch (e) { + debug( + `Error rendering body template for ${compoundId}: ${e.message}`, + ); + return res.status(500).json({ + error: "Error rendering body template", + message: e.message, + }); + } + } + + return res.send(body); } next(); }); }; +let engine = null; + +(function initEngine() { + engine = new Liquid({ + parseLimit: 1e6, // 1M, typical size ((character length)) of your templates in each render + renderLimit: 500, // 500ms, limit the time consumed by each render + memoryLimit: 1e7, // 10M, memory available for LiquidJS (1e9 for 1GB) + ownPropertyOnly: true, // Disallow access to prototype properties to prevent prototype pollution attacks + strictVariables: false, // Undefined variables will be rendered as empty string + strictFilters: true, // Throw an error when using undefined filters to avoid unintended behavior + lenientIf: true, // Treat undefined or non-boolean values as falsy in if/unless conditions, avoids render errors + cache: false, // Disable template caching + }); + + const builtInFilterWhiteList = ["default"]; + + function disabledFilter(name) { + return function () { + throw new Error(`filter "${name}" unsupported`); + }; + } + + for (const filterName in engine.filters) { + if (!builtInFilterWhiteList.includes(filterName)) { + engine.registerFilter(filterName, disabledFilter(filterName)); + } + } + + const builtInTagWhiteList = [ + "assign", + /* "break", + "continue", + "for", */ + "if", + "raw", + "unless", + ]; + + const disabledTag = { + parse: function (token) { + throw new Error(`tag "${token.name}" unsupported`); + }, + }; + + for (const tagName in engine.tags) { + if (!builtInTagWhiteList.includes(tagName)) { + engine.registerTag(tagName, disabledTag); + } + } +})(); + +function renderBody({ template, req }) { + const pathSegments = req.params[0].split("/"); + pathSegments.shift(); // remove the leading slash + + return engine.parseAndRenderSync(template, { + faker: new FakerDrop(), + req: { + headers: new HeadersDrop(req.headers), + queryParams: req.query, + pathSegments, + body: new BodyDrop(req), + }, + }); +} + +const MAX_BODY_CHAR_LENGTH = 1000000; // 1 M +const reqSymbol = Symbol("req"); +const multipartContetTypes = [ + "multipart/mixed", + "multipart/related", + "multipart/form-data", + "multipart/alternate", +]; +class BodyDrop extends Drop { + constructor(req) { + super(); + // use a symbol to avoid name collisions with the headers + this[reqSymbol] = req; + } + valueOf() { + // return the whole body as a string + // truncated to MAX_BODY_CHAR_LENGTH + return this[reqSymbol]?.body?.substring(0, MAX_BODY_CHAR_LENGTH) || ""; + } + liquidMethodMissing(key) { + const req = this[reqSymbol]; + if (req.contentType === "application/json" && req.jsonBody) { + return req.jsonBody[key]; + } + if ( + req.contentType === "application/x-www-form-urlencoded" && + req.formBody + ) { + return req.formBody[key]; + } + if (multipartContetTypes.includes(req.contentType) && req.multiPartSimple) { + // req.multiPartSimple[key] is either a string or an array of strings here + return req.multiPartSimple[key]; + } + // do not support parsing body for other content types + return undefined; + } +} + +const headersSymbol = Symbol("headers"); +class HeadersDrop extends Drop { + constructor(headers) { + super(); + // use a symbol to avoid name collisions with the headers + this[headersSymbol] = headers; + } + liquidMethodMissing(key) { + // normalize key to lowercase since keys in req.headers are lower-cased. + // https://nodejs.org/api/http.html#http_message_headers + const lowercasedKey = key.toLowerCase(); + if ( + Object.prototype.hasOwnProperty.call( + this[headersSymbol], + lowercasedKey, + ) && + typeof this[headersSymbol][lowercasedKey] === "string" + ) { + return this[headersSymbol][lowercasedKey]; + } + return undefined; + } +} + +class FakerDrop extends Drop { + liquidMethodMissing(key) { + if ( + Object.prototype.hasOwnProperty.call(fakerFunctions, key) && + typeof fakerFunctions[key] === "function" + ) { + return fakerFunctions[key](); + } + return undefined; + } +} + +const fakerFunctions = { + guid: () => faker.string.uuid(), + timestamp: () => faker.date.anytime().getTime().toString(), + isoTimestamp: () => faker.date.anytime().toISOString(), + randomUUID: () => faker.string.uuid(), + randomAlphaNumeric: () => faker.string.alphanumeric(), + randomBoolean: () => faker.datatype.boolean(), + randomInt: () => faker.number.int(), + randomColor: () => faker.color.human(), + randomHexColor: () => faker.internet.color(), + randomAbbreviation: () => faker.hacker.abbreviation(), + randomIP: () => faker.internet.ip(), + randomIPV6: () => faker.internet.ipv6(), + randomMACAddress: () => faker.internet.mac(), + randomPassword: () => faker.internet.password(), + randomLocale: () => faker.location.countryCode(), + randomUserAgent: () => faker.internet.userAgent(), + randomProtocol: () => faker.internet.protocol(), + randomSemver: () => faker.system.semver(), + randomFirstName: () => faker.person.firstName(), + randomLastName: () => faker.person.lastName(), + randomFullName: () => faker.person.fullName(), + randomNamePrefix: () => faker.person.prefix(), + randomNameSuffix: () => faker.person.suffix(), + randomJobArea: () => faker.person.jobArea(), + randomJobDescriptor: () => faker.person.jobDescriptor(), + randomJobTitle: () => faker.person.jobTitle(), + randomJobType: () => faker.person.jobType(), + randomPhoneNumber: () => faker.phone.number(), + randomPhoneNumberExt: () => faker.phone.number(), + randomCity: () => faker.location.city(), + randomStreetName: () => faker.location.street(), + randomStreetAddress: () => faker.location.streetAddress(), + randomCountry: () => faker.location.country(), + randomCountryCode: () => faker.location.countryCode(), + randomLatitude: () => faker.location.latitude(), + randomLongitude: () => faker.location.longitude(), + randomAvatarImage: () => faker.image.avatar(), + randomImageUrl: () => faker.image.url(), + randomAbstractImage: () => + faker.image.urlLoremFlickr({ category: "abstract" }), + randomAnimalsImage: () => faker.image.urlLoremFlickr({ category: "animals" }), + randomBusinessImage: () => + faker.image.urlLoremFlickr({ category: "business" }), + randomCatsImage: () => faker.image.urlLoremFlickr({ category: "cats" }), + randomCityImage: () => faker.image.urlLoremFlickr({ category: "city" }), + randomFoodImage: () => faker.image.urlLoremFlickr({ category: "food" }), + randomNightlifeImage: () => + faker.image.urlLoremFlickr({ category: "nightlife" }), + randomFashionImage: () => faker.image.urlLoremFlickr({ category: "fashion" }), + randomPeopleImage: () => faker.image.urlLoremFlickr({ category: "people" }), + randomNatureImage: () => faker.image.urlLoremFlickr({ category: "nature" }), + randomSportsImage: () => faker.image.urlLoremFlickr({ category: "sports" }), + randomTransportImage: () => + faker.image.urlLoremFlickr({ category: "transport" }), + randomImageDataUri: () => faker.image.dataUri(), + randomBankAccount: () => faker.finance.accountNumber(), + randomBankAccountName: () => faker.finance.accountName(), + randomCreditCardMask: () => faker.finance.maskedNumber(), + randomBankAccountBic: () => faker.finance.bic(), + randomBankAccountIban: () => faker.finance.iban(), + randomTransactionType: () => faker.finance.transactionType(), + randomCurrencyCode: () => faker.finance.currencyCode(), + randomCurrencyName: () => faker.finance.currencyName(), + randomCurrencySymbol: () => faker.finance.currencySymbol(), + randomBitcoin: () => faker.finance.bitcoinAddress(), + randomCompanyName: () => faker.company.name(), + randomCompanySuffix: () => faker.company.name(), + randomBs: () => faker.company.buzzPhrase(), + randomBsAdjective: () => faker.company.buzzAdjective(), + randomBsBuzz: () => faker.company.buzzVerb(), + randomBsNoun: () => faker.company.buzzNoun(), + randomCatchPhrase: () => faker.company.catchPhrase(), + randomCatchPhraseAdjective: () => faker.company.catchPhraseAdjective(), + randomCatchPhraseDescriptor: () => faker.company.catchPhraseDescriptor(), + randomCatchPhraseNoun: () => faker.company.catchPhraseNoun(), + randomDatabaseColumn: () => faker.database.column(), + randomDatabaseType: () => faker.database.type(), + randomDatabaseCollation: () => faker.database.collation(), + randomDatabaseEngine: () => faker.database.engine(), + randomDateFuture: () => faker.date.future().toISOString(), + randomDatePast: () => faker.date.past().toISOString(), + randomDateRecent: () => faker.date.recent().toISOString(), + randomWeekday: () => faker.date.weekday(), + randomMonth: () => faker.date.month(), + randomDomainName: () => faker.internet.domainName(), + randomDomainSuffix: () => faker.internet.domainSuffix(), + randomDomainWord: () => faker.internet.domainWord(), + randomEmail: () => faker.internet.email(), + randomExampleEmail: () => faker.internet.exampleEmail(), + randomUserName: () => faker.internet.userName(), + randomUrl: () => faker.internet.url(), + randomFileName: () => faker.system.fileName(), + randomFileType: () => faker.system.fileType(), + randomFileExt: () => faker.system.fileExt(), + randomCommonFileName: () => faker.system.commonFileName(), + randomCommonFileType: () => faker.system.commonFileType(), + randomCommonFileExt: () => faker.system.commonFileExt(), + randomFilePath: () => faker.system.filePath(), + randomDirectoryPath: () => faker.system.directoryPath(), + randomMimeType: () => faker.system.mimeType(), + randomPrice: () => faker.commerce.price(), + randomProduct: () => faker.commerce.product(), + randomProductAdjective: () => faker.commerce.productAdjective(), + randomProductMaterial: () => faker.commerce.productMaterial(), + randomProductName: () => faker.commerce.productName(), + randomDepartment: () => faker.commerce.department(), + randomNoun: () => faker.hacker.noun(), + randomVerb: () => faker.hacker.verb(), + randomIngverb: () => faker.hacker.ingverb(), + randomAdjective: () => faker.hacker.adjective(), + randomWord: () => faker.hacker.noun(), + randomWords: () => faker.lorem.words(), + randomPhrase: () => faker.hacker.phrase(), + randomLoremWord: () => faker.lorem.word(), + randomLoremWords: () => faker.lorem.words(), + randomLoremSentence: () => faker.lorem.sentence(), + randomLoremSentences: () => faker.lorem.sentences(), + randomLoremParagraph: () => faker.lorem.paragraph(), + randomLoremParagraphs: () => faker.lorem.paragraphs(), + randomLoremText: () => faker.lorem.text(), + randomLoremSlug: () => faker.lorem.slug(), + randomLoremLines: () => faker.lorem.lines(), +}; + function removeSensitiveData(entry) { const url = entry.request.url; const idx = url.indexOf("?"); diff --git a/package-lock.json b/package-lock.json index 2fd6bfb..8314dcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "mockbin", - "version": "2.0.13", + "version": "2.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mockbin", - "version": "2.0.13", + "version": "2.0.14", "license": "MIT", "dependencies": { + "@faker-js/faker": "^9.8.0", "@idio/dicer": "1.1.0", "change-case": "^4.1.1", "compression": "^1.7.4", @@ -19,6 +20,7 @@ "express": "^4.17.1", "har-validator": "^5.1.3", "jstransformer-marked": "^1.4.0", + "liquidjs": "^10.21.1", "method-override": "^3.0.0", "morgan": "^1.9.1", "pug": "^3.0.3", @@ -235,6 +237,22 @@ "node": ">=14.21.3" } }, + "node_modules/@faker-js/faker": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.8.0.tgz", + "integrity": "sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, "node_modules/@idio/dicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@idio/dicer/-/dicer-1.1.0.tgz", @@ -661,6 +679,15 @@ "node": ">=0.10.0" } }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/compressible": { "version": "2.0.18", "license": "MIT", @@ -1467,6 +1494,26 @@ "node": ">= 12" } }, + "node_modules/liquidjs": { + "version": "10.21.1", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.21.1.tgz", + "integrity": "sha512-NZXmCwv3RG5nire3fmIn9HsOyJX3vo+ptp0yaXUHAMzSNBhx74Hm+dAGJvscUA6lNqbLuYfXgNavRQ9UbUJhQQ==", + "license": "MIT", + "dependencies": { + "commander": "^10.0.0" + }, + "bin": { + "liquid": "bin/liquid.js", + "liquidjs": "bin/liquid.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/liquidjs" + } + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", diff --git a/package.json b/package.json index ce2b954..df37624 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "2.0.13", + "version": "2.0.14", "name": "mockbin", "description": "Test, mock, and track HTTP requests & responses between libraries, sockets and APIs", "author": "Kong (https://www.konghq.com/)", @@ -49,16 +49,18 @@ "should": "^13.2.3" }, "dependencies": { + "@faker-js/faker": "^9.8.0", + "@idio/dicer": "1.1.0", "change-case": "^4.1.1", "compression": "^1.7.4", "content-type": "^1.0.4", "cookie-parser": "^1.4.5", "debug": "^4.3.4", - "@idio/dicer": "1.1.0", "dotenv": "^16.4.0", "express": "^4.17.1", "har-validator": "^5.1.3", "jstransformer-marked": "^1.4.0", + "liquidjs": "^10.21.1", "method-override": "^3.0.0", "morgan": "^1.9.1", "pug": "^3.0.3",