Skip to content

Commit 6684a34

Browse files
fix(reporter): isolate reporter errors so a hostile failure payload can't corrupt the run
1 parent f6e8d9a commit 6684a34

4 files changed

Lines changed: 173 additions & 19 deletions

File tree

lib/reporters/base.mjs

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,12 @@ export class Base {
8383
});
8484

8585
runner.on(EVENT_TEST_FAIL, function (test, err) {
86-
if (Base.showDiff(err)) {
87-
stringifyDiffObjs(err);
86+
try {
87+
if (Base.showDiff(err)) {
88+
stringifyDiffObjs(err);
89+
}
90+
} catch (diffErr) {
91+
warnReporterError("preparing the failure diff", diffErr);
8892
}
8993
// more than one error per test
9094
if (test.err && err instanceof Error) {
@@ -325,27 +329,52 @@ function estimateSize(val, maxDepth, seen) {
325329
return 50; // Default estimate for other types
326330
}
327331

332+
/**
333+
* Warn (on a best effort basis) that the reporter itself threw while handling a failure.
334+
* Used so a hostile error payload cannot silently suppress the non-zero exit code.
335+
*
336+
* @private
337+
* @param {string} context - what the reporter was doing when it threw
338+
* @param {*} err - the error the reporter threw
339+
*/
340+
function warnReporterError(context, err) {
341+
try {
342+
process.stderr.write(
343+
"\n[mocha] reporter error while " +
344+
context +
345+
": " +
346+
(err && err.stack ? err.stack : String(err)) +
347+
"\n",
348+
);
349+
} catch {}
350+
}
351+
328352
function stringifyDiffObjs(err) {
329353
if (!utils.isString(err.actual) || !utils.isString(err.expected)) {
330354
// Estimate size before stringifying to avoid hangs
331355
const maxSafeSize = Base.maxDiffSize || 8192;
332-
const actualSize = estimateSize(err.actual, 10);
333-
const expectedSize = estimateSize(err.expected, 10);
334-
335-
if (
336-
actualSize === -1 ||
337-
expectedSize === -1 ||
338-
actualSize > maxSafeSize ||
339-
expectedSize > maxSafeSize
340-
) {
341-
// Values too large/complex - provide safe fallback
342-
err.actual = "[object too large to diff]";
343-
err.expected = "[object too large to diff]";
344-
return;
345-
}
356+
try {
357+
const actualSize = estimateSize(err.actual, 10);
358+
const expectedSize = estimateSize(err.expected, 10);
359+
360+
if (
361+
actualSize === -1 ||
362+
expectedSize === -1 ||
363+
actualSize > maxSafeSize ||
364+
expectedSize > maxSafeSize
365+
) {
366+
// Values too large/complex - provide safe fallback
367+
err.actual = "[object too large to diff]";
368+
err.expected = "[object too large to diff]";
369+
return;
370+
}
346371

347-
err.actual = utils.stringify(err.actual);
348-
err.expected = utils.stringify(err.expected);
372+
err.actual = utils.stringify(err.actual);
373+
err.expected = utils.stringify(err.expected);
374+
} catch {
375+
err.actual = "[object could not be stringified]";
376+
err.expected = "[object could not be stringified]";
377+
}
349378
}
350379
}
351380

@@ -454,6 +483,19 @@ Base.list = function (failures) {
454483
var multipleErr, multipleTest;
455484
Base.consoleLog();
456485
failures.forEach(function (test, i) {
486+
try {
487+
renderOneFailure(test, i);
488+
} catch (renderErr) {
489+
Base.consoleLog(
490+
" %s) %s:\n [mocha] failed to render error: %s",
491+
i + 1,
492+
test && test.titlePath ? test.titlePath().join(" ") : "<unknown>",
493+
renderErr && renderErr.message ? renderErr.message : renderErr,
494+
);
495+
}
496+
});
497+
498+
function renderOneFailure(test, i) {
457499
// format
458500
var fmt =
459501
Base.color("error title", " %s) %s:\n") +
@@ -506,7 +548,7 @@ Base.list = function (failures) {
506548
});
507549

508550
Base.consoleLog(fmt, i + 1, testTitle, msg, stack);
509-
});
551+
}
510552
};
511553

512554
/**
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"use strict";
2+
3+
const assert = require("node:assert");
4+
5+
it("listener-throws", () => {
6+
const poison = new Proxy(
7+
{},
8+
{
9+
ownKeys() {
10+
throw new TypeError("poisoned ownKeys");
11+
},
12+
getOwnPropertyDescriptor() {
13+
throw new TypeError("poisoned descriptor");
14+
},
15+
get() {
16+
throw new TypeError("poisoned get");
17+
},
18+
},
19+
);
20+
throw new assert.AssertionError({
21+
actual: poison,
22+
expected: { a: 1 },
23+
message: "boom",
24+
});
25+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"use strict";
2+
3+
const { invokeMochaAsync, resolveFixturePath } = require("./helpers");
4+
5+
describe("reporter resilience", function () {
6+
this.timeout(10000);
7+
8+
let res;
9+
10+
before(async function () {
11+
const [, promise] = invokeMochaAsync(
12+
[resolveFixturePath("reporters/listener-throws")],
13+
{ stdio: "pipe" },
14+
);
15+
res = await promise;
16+
});
17+
18+
it("should exit non-zero when the reporter throws while handling a failure", function () {
19+
expect(res, "to have failed");
20+
});
21+
22+
it("should warn that the reporter threw instead of crashing", function () {
23+
expect(
24+
res.output,
25+
"to match",
26+
/\[mocha\] (reporter error|failed to render)/,
27+
);
28+
});
29+
30+
it("should report the failure exactly once, not double-count the reporter throw", function () {
31+
expect(res.output, "to match", /1 failing/);
32+
expect(res.output, "not to contain", "2 failing");
33+
});
34+
});

test/reporters/base.spec.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,59 @@ describe("Base reporter", function () {
593593
});
594594
});
595595

596+
describe("reporter resilience", function () {
597+
it("should not throw out of Base.list when rendering a failure throws", function () {
598+
var poison = new Proxy(
599+
{},
600+
{
601+
ownKeys: function () {
602+
throw new TypeError("poisoned ownKeys");
603+
},
604+
getOwnPropertyDescriptor: function () {
605+
throw new TypeError("poisoned descriptor");
606+
},
607+
get: function () {
608+
throw new TypeError("poisoned get");
609+
},
610+
},
611+
);
612+
var err = new AssertionError({
613+
actual: poison,
614+
expected: { a: 1 },
615+
message: "boom",
616+
});
617+
var test = makeTest(err);
618+
619+
expect(function () {
620+
list([test]);
621+
}, "not to throw");
622+
623+
var errOut = stdout.join("\n");
624+
expect(errOut, "to contain", "test title");
625+
expect(errOut, "to contain", "failed to render error");
626+
});
627+
628+
it("should fall back to a placeholder when stringifyDiffObjs throws", function () {
629+
var utils = require("../../lib/utils");
630+
sinon
631+
.stub(utils, "stringify")
632+
.throws(new Error("simulated stringify failure"));
633+
var err = new AssertionError({
634+
actual: { a: 1 },
635+
expected: { a: 2 },
636+
message: "boom",
637+
});
638+
var test = makeTest(err);
639+
640+
expect(function () {
641+
list([test]);
642+
}, "not to throw");
643+
var errOut = stdout.join("\n");
644+
expect(errOut, "to contain", "test title");
645+
expect(errOut, "not to contain", "failed to render error");
646+
});
647+
});
648+
596649
it("should list multiple Errors per test", function () {
597650
var err = new Error("First Error");
598651
err.multiple = [new Error("Second Error - same test")];

0 commit comments

Comments
 (0)