From 2a91da5aa6a531b8d603778cef811426a689fde3 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sat, 28 Oct 2023 13:15:38 +0200 Subject: [PATCH] Extend ReDoS testing with general performance tests (#1267) Add general performance/ReDoS tests modeled after `fast-check`'s blog post on the topic[1]. This complements the ReDoS regression tests (for Unix) in that these tests will only catch some ReDoS regression. In fact, based on testing, this project's ReDoS CVEs wouldn't have been caught by the tests being added here. -- 1. https://fast-check.dev/blog/2023/10/05/finding-back-a-redos-vulnerability-in-zod/ --- test/unit/_macros.js | 33 +++++++++++++++++++++++++++++++++ test/unit/unix/shells.test.js | 35 ++++++++++++++++++++++++++++------- test/unit/win/shells.test.js | 21 +++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/test/unit/_macros.js b/test/unit/_macros.js index f5bb636da..9baa2b86c 100644 --- a/test/unit/_macros.js +++ b/test/unit/_macros.js @@ -3,7 +3,10 @@ * @license MIT */ +import { performance } from "node:perf_hooks"; + import test from "ava"; +import fc from "fast-check"; /** * Transforms a string by replacing control characters with unicode point codes @@ -94,6 +97,36 @@ export const flag = test.macro({ }, }); +/** + * The flag macro tests the behaviour of the function returned by the provided + * `getFlagProtectionFunction`. + * + * @param {object} t The AVA test object. + * @param {object} args The arguments for this function. + * @param {any} args.arbitraries The arbitraries to test with. + * @param {number} args.maxMillis The maximum duration in milliseconds. + * @param {Function} args.setup A function to setup the function to test. + */ +export const duration = test.macro({ + exec(t, { arbitraries, maxMillis, setup }) { + fc.assert( + fc.property(...arbitraries, (...args) => { + const fn = setup(); + + const startTime = performance.now(); + try { + fn(...args); + } catch (_) { + // not concerned about functional correctness + } + const endTime = performance.now(); + + t.true(endTime - startTime < maxMillis); + }), + ); + }, +}); + /** * The quote macro tests the behaviour of the function returned by the provided * `getQuoteFunction`. diff --git a/test/unit/unix/shells.test.js b/test/unit/unix/shells.test.js index 3487b7423..d87fc1313 100644 --- a/test/unit/unix/shells.test.js +++ b/test/unit/unix/shells.test.js @@ -44,6 +44,20 @@ for (const [shellName, shellExports] of Object.entries(shells)) { t.is(typeof result, "string"); }); + test(`escape performance for ${shellName}`, macros.duration, { + arbitraries: [fc.string({ size: "xlarge" })], + maxMillis: 50, + setup: shellExports.getEscapeFunction, + }); + + redosFixtures.forEach((input, id) => { + test(`${shellName}, ReDoS #${id}`, (t) => { + const escape = shellExports.getEscapeFunction(); + escape(input); + t.pass(); + }); + }); + flagFixtures.forEach(({ input, expected }) => { test(macros.flag, { expected: expected.unquoted, @@ -63,6 +77,12 @@ for (const [shellName, shellExports] of Object.entries(shells)) { }, ); + test(`flag protection performance for ${shellName}`, macros.duration, { + arbitraries: [fc.string({ size: "xlarge" })], + maxMillis: 50, + setup: shellExports.getFlagProtectionFunction, + }); + if (shellExports !== nosh) { quoteFixtures.forEach(({ input, expected }) => { test(macros.quote, { @@ -80,13 +100,14 @@ for (const [shellName, shellExports] of Object.entries(shells)) { const result = quoteFn(intermediate); t.is(typeof result, "string"); }); - } - redosFixtures.forEach((input, id) => { - test(`${shellName}, ReDoS #${id}`, (t) => { - const escape = shellExports.getEscapeFunction(); - escape(input); - t.pass(); + test(`quote performance for ${shellName}`, macros.duration, { + arbitraries: [fc.string({ size: "xlarge" })], + maxMillis: 50, + setup: () => { + const [escapeFn, quoteFn] = shellExports.getQuoteFunction(); + return (arg) => quoteFn(escapeFn(arg)); + }, }); - }); + } } diff --git a/test/unit/win/shells.test.js b/test/unit/win/shells.test.js index 101db9e87..e004b2370 100644 --- a/test/unit/win/shells.test.js +++ b/test/unit/win/shells.test.js @@ -39,6 +39,12 @@ for (const [shellName, shellExports] of Object.entries(shells)) { t.is(typeof result, "string"); }); + test(`escape performance for ${shellName}`, macros.duration, { + arbitraries: [fc.string({ size: "xlarge" })], + maxMillis: 50, + setup: shellExports.getEscapeFunction, + }); + flagFixtures.forEach(({ input, expected }) => { test(macros.flag, { expected: expected.unquoted, @@ -58,6 +64,12 @@ for (const [shellName, shellExports] of Object.entries(shells)) { }, ); + test(`flag protection performance for ${shellName}`, macros.duration, { + arbitraries: [fc.string({ size: "xlarge" })], + maxMillis: 50, + setup: shellExports.getFlagProtectionFunction, + }); + if (shellExports !== nosh) { quoteFixtures.forEach(({ input, expected }) => { test(macros.quote, { @@ -75,5 +87,14 @@ for (const [shellName, shellExports] of Object.entries(shells)) { const result = quoteFn(intermediate); t.is(typeof result, "string"); }); + + test(`quote performance for ${shellName}`, macros.duration, { + arbitraries: [fc.string({ size: "xlarge" })], + maxMillis: 50, + setup: () => { + const [escapeFn, quoteFn] = shellExports.getQuoteFunction(); + return (arg) => quoteFn(escapeFn(arg)); + }, + }); } }