Information disclosure
Description
What was fixed: The commit changes how util.inspect formats errors that are thrown by property getters. Previously, when a getter threw or when an error object had problematic properties, util.inspect could access err.message directly inside the catch block to build the inspection string. If err.message (or nested properties) used getters that throw or reveal sensitive data, this could lead to information disclosure or unexpected crashes during object inspection. The patch switches to formatting the entire error value using formatValue(ctx, err, recurseTimes) instead of directly using err.message, and it adjusts indentation around the getter handling. Tests were added to cover various edge cases, including nested causes, circular references, and unusual error values. In short, this is a real vulnerability fix for information disclosure via util.inspect when getters throw, and it adds safer, sanitized formatting for such errors.
Proof of Concept
/* Proof-of-concept demonstrating the vulnerability pre-fix and the safer post-fix behavior */
const util = require('util');
// Create an error whose message getter throws (simulating sensitive read or a getter with side effects)
const badError = new Error('secret-token=TOP-SECRET');
Object.defineProperty(badError, 'message', {
configurable: true,
enumerable: true,
get() { throw new Error('Cannot read message'); }
});
// Object with a getter that throws the above error
const obj = {
get foo() { throw badError; }
};
console.log('Pre-fix behavior (may crash or leak error details):');
try {
// In older Node versions, util.inspect would access err.message here and could throw again
console.log(util.inspect(obj, { getters: true }));
} catch (e) {
console.log('util.inspect crashed or leaked details:', e);
}
// Post-fix concept (safe formatting): util.inspect uses a safe formatting path for errors
// The output should be a sanitized representation of the getter failure, not a raw err.message
console.log('\nPost-fix sanitized output (conceptual):');
try {
console.log(util.inspect(obj, { getters: true }));
} catch (e) {
// If something unexpected occurs, at least we catch it
console.log('Unexpected throw during post-fix path:', e);
}
Commit Details
Author: Yves M.
Date: 2025-11-27 11:06 UTC
Message:
util: safely inspect getter errors whose message throws
PR-URL: https://github.com/nodejs/node/pull/60684
Fixes: https://github.com/nodejs/node/issues/60683
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Triage Assessment
Vulnerability Type: Information disclosure
Confidence: HIGH
Reasoning:
The patch changes how errors thrown by property getters are represented during util.inspect. Instead of directly using err.message, it formats the error safely, preventing potential leakage or formatting issues if the getter's error, its message, or nested causes throw or contain sensitive data. This mitigates information disclosure and unexpected crashes when inspecting objects with problematic getters.
Verification Assessment
Vulnerability Type: Information disclosure
Confidence: HIGH
Affected Versions: Node.js 25.x pre-25.9.0 (i.e., 25.0.0 through 25.8.x); fix included in 25.9.0 and later
Code Diff
diff --git a/lib/internal/util/inspect.js b/lib/internal/util/inspect.js
index 83c254c3d6c464..e7b0063318c8c8 100644
--- a/lib/internal/util/inspect.js
+++ b/lib/internal/util/inspect.js
@@ -2544,9 +2544,9 @@ function formatProperty(ctx, value, recurseTimes, key, type, desc,
if (ctx.getters && (ctx.getters === true ||
(ctx.getters === 'get' && desc.set === undefined) ||
(ctx.getters === 'set' && desc.set !== undefined))) {
+ ctx.indentationLvl += 2;
try {
const tmp = FunctionPrototypeCall(desc.get, original);
- ctx.indentationLvl += 2;
if (tmp === null) {
str = `${s(`[${label}:`, sp)} ${s('null', 'null')}${s(']', sp)}`;
} else if (typeof tmp === 'object') {
@@ -2555,11 +2555,11 @@ function formatProperty(ctx, value, recurseTimes, key, type, desc,
const primitive = formatPrimitive(s, tmp, ctx);
str = `${s(`[${label}:`, sp)} ${primitive}${s(']', sp)}`;
}
- ctx.indentationLvl -= 2;
} catch (err) {
- const message = `<Inspection threw (${err.message})>`;
+ const message = `<Inspection threw (${formatValue(ctx, err, recurseTimes)})>`;
str = `${s(`[${label}:`, sp)} ${message}${s(']', sp)}`;
}
+ ctx.indentationLvl -= 2;
} else {
str = ctx.stylize(`[${label}]`, sp);
}
diff --git a/test/parallel/test-util-inspect.js b/test/parallel/test-util-inspect.js
index 8178e7b451e31c..d64c676d6403f9 100644
--- a/test/parallel/test-util-inspect.js
+++ b/test/parallel/test-util-inspect.js
@@ -690,7 +690,9 @@ assert.strictEqual(util.inspect(-5e-324), '-5e-324');
{
const tmp = Error.stackTraceLimit;
- Error.stackTraceLimit = 0;
+ // Force stackTraceLimit = 0 for this test, but make it non-enumerable
+ // so it doesn't appear in inspect() output when inspecting Error in other tests.
+ Object.defineProperty(Error, 'stackTraceLimit', { value: 0, enumerable: false });
const err = new Error('foo');
const err2 = new Error('foo\nbar');
assert.strictEqual(util.inspect(err, { compact: true }), '[Error: foo]');
@@ -2527,14 +2529,10 @@ assert.strictEqual(
set foo(val) { foo = val; },
get inc() { return ++foo; }
};
- const thrower = { get foo() { throw new Error('Oops'); } };
assert.strictEqual(
inspect(get, { getters: true, colors: true }),
'{ foo: \u001b[36m[Getter:\u001b[39m ' +
'\u001b[33m1\u001b[39m\u001b[36m]\u001b[39m }');
- assert.strictEqual(
- inspect(thrower, { getters: true }),
- '{ foo: [Getter: <Inspection threw (Oops)>] }');
assert.strictEqual(
inspect(getset, { getters: true }),
'{ foo: [Getter/Setter: 1], inc: [Getter: 2] }');
@@ -2551,6 +2549,239 @@ assert.strictEqual(
"'foobar', { x: 1 } },\n inc: [Getter: NaN]\n}");
}
+// Property getter throwing an error.
+{
+ const error = new Error('Oops');
+ error.stack = [
+ 'Error: Oops',
+ ' at get foo (/foo/node_modules/foo.js:2:7)',
+ ' at get bar (/foo/node_modules/bar.js:827:30)',
+ ].join('\n');
+
+ const thrower = {
+ get foo() { throw error; }
+ };
+
+ assert.strictEqual(
+ inspect(thrower, { getters: true }),
+ '{\n' +
+ ' foo: [Getter: <Inspection threw (Error: Oops\n' +
+ ' at get foo (/foo/node_modules/foo.js:2:7)\n' +
+ ' at get bar (/foo/node_modules/bar.js:827:30))>]\n' +
+ '}',
+ );
+};
+
+// Property getter throwing an error with getters that throws.
+// https://github.com/nodejs/node/issues/60683
+{
+ const badError = new Error();
+
+ const innerError = new Error('Oops');
+ innerError.stack = [
+ 'Error: Oops',
+ ' at get foo (/foo/node_modules/foo.js:2:7)',
+ ' at get bar (/foo/node_modules/bar.js:827:30)',
+ ].join('\n');
+
+ const throwingGetter = {
+ __proto__: null,
+ get() {
+ throw innerError;
+ },
+ configurable: true,
+ enumerable: true,
+ };
+
+ Object.defineProperties(badError, {
+ name: throwingGetter,
+ message: throwingGetter,
+ stack: throwingGetter,
+ cause: throwingGetter,
+ });
+
+ const thrower = {
+ get foo() { throw badError; }
+ };
+
+ assert.strictEqual(
+ inspect(thrower, { getters: true }),
+ '{\n' +
+ ' foo: [Getter: <Inspection threw ([object Error] {\n' +
+ ' stack: [Getter/Setter: <Inspection threw (Error: Oops\n' +
+ ' at get foo (/foo/node_modules/foo.js:2:7)\n' +
+ ' at get bar (/foo/node_modules/bar.js:827:30))>],\n' +
+ ' name: [Getter: <Inspection threw (Error: Oops\n' +
+ ' at get foo (/foo/node_modules/foo.js:2:7)\n' +
+ ' at get bar (/foo/node_modules/bar.js:827:30))>],\n' +
+ ' message: [Getter: <Inspection threw (Error: Oops\n' +
+ ' at get foo (/foo/node_modules/foo.js:2:7)\n' +
+ ' at get bar (/foo/node_modules/bar.js:827:30))>],\n' +
+ ' cause: [Getter: <Inspection threw (Error: Oops\n' +
+ ' at get foo (/foo/node_modules/foo.js:2:7)\n' +
+ ' at get bar (/foo/node_modules/bar.js:827:30))>]\n' +
+ ' })>]\n' +
+ '}'
+ );
+}
+
+// Property getter throwing an error with getters that throws recursivly.
+{
+ const recursivelyThrowingErrorDesc = {
+ __proto__: null,
+ // eslint-disable-next-line no-restricted-syntax
+ get() { throw createRecursivelyThrowingError(); },
+ configurable: true,
+ enumerable: true,
+ };
+ const createRecursivelyThrowingError = () =>
+ Object.defineProperties(new Error(), {
+ cause: recursivelyThrowingErrorDesc,
+ name: recursivelyThrowingErrorDesc,
+ message: recursivelyThrowingErrorDesc,
+ stack: recursivelyThrowingErrorDesc,
+ });
+ const thrower = Object.defineProperty({}, 'foo', recursivelyThrowingErrorDesc);
+
+ assert.strictEqual(
+ inspect(thrower, { getters: true, depth: 1 }),
+ '{\n' +
+ ' foo: [Getter: <Inspection threw ([object Error] {\n' +
+ ' stack: [Getter/Setter: <Inspection threw ([Error])>],\n' +
+ ' cause: [Getter: <Inspection threw ([Error])>],\n' +
+ ' name: [Getter: <Inspection threw ([Error])>],\n' +
+ ' message: [Getter: <Inspection threw ([Error])>]\n' +
+ ' })>]\n' +
+ '}'
+ );
+
+ [{ getters: true, depth: 2 }, { getters: true }].forEach((options) => {
+ assert.strictEqual(
+ inspect(thrower, options),
+ '{\n' +
+ ' foo: [Getter: <Inspection threw ([object Error] {\n' +
+ ' stack: [Getter/Setter: <Inspection threw ([object Error] {\n' +
+ ' stack: [Getter/Setter: <Inspection threw ([Error])>],\n' +
+ ' cause: [Getter: <Inspection threw ([Error])>],\n' +
+ ' name: [Getter: <Inspection threw ([Error])>],\n' +
+ ' message: [Getter: <Inspection threw ([Error])>]\n' +
+ ' })>],\n' +
+ ' cause: [Getter: <Inspection threw ([object Error] {\n' +
+ ' stack: [Getter/Setter: <Inspection threw ([Error])>],\n' +
+ ' cause: [Getter: <Inspection threw ([Error])>],\n' +
+ ' name: [Getter: <Inspection threw ([Error])>],\n' +
+ ' message: [Getter: <Inspection threw ([Error])>]\n' +
+ ' })>],\n' +
+ ' name: [Getter: <Inspection threw ([object Error] {\n' +
+ ' stack: [Getter/Setter: <Inspection threw ([Error])>],\n' +
+ ' cause: [Getter: <Inspection threw ([Error])>],\n' +
+ ' name: [Getter: <Inspection threw ([Error])>],\n' +
+ ' message: [Getter: <Inspection threw ([Error])>]\n' +
+ ' })>],\n' +
+ ' message: [Getter: <Inspection threw ([object Error] {\n' +
+ ' stack: [Getter/Setter: <Inspection threw ([Error])>],\n' +
+ ' cause: [Getter: <Inspection threw ([Error])>],\n' +
+ ' name: [Getter: <Inspection threw ([Error])>],\n' +
+ ' message: [Getter: <Inspection threw ([Error])>]\n' +
+ ' })>]\n' +
+ ' })>]\n' +
+ '}'
+ );
+ });
+}
+
+// Property getter throwing an error whose own getters throw that same error (infinite recursion).
+{
+ const badError = new Error();
+
+ const throwingGetter = {
+ __proto__: null,
+ get() {
+ throw badError;
+ },
+ configurable: true,
+ enumerable: true,
+ };
+
+ Object.defineProperties(badError, {
+ name: throwingGetter,
+ message: throwingGetter,
+ stack: throwingGetter,
+ cause: throwingGetter,
+ });
+
+ const thrower = {
+ get foo() { throw badError; }
+ };
+
+ assert.strictEqual(
+ inspect(thrower, { getters: true, depth: Infinity }),
+ '{\n' +
+ ' foo: [Getter: <Inspection threw (<ref *1> [object Error] {\n' +
+ ' stack: [Getter/Setter: <Inspection threw ([Circular *1])>],\n' +
+ ' name: [Getter: <Inspection threw ([Circular *1])>],\n' +
+ ' message: [Getter: <Inspection threw ([Circular *1])>],\n' +
+ ' cause: [Getter: <Inspection threw ([Circular *1])>]\n' +
+ ' })>]\n' +
+ '}'
+ );
+}
+
+// Property getter throwing uncommon values.
+[
+ {
+ val: undefined,
+ expected: '{ foo: [Getter: <Inspection threw (undefined)>] }'
+ },
+ {
+ val: null,
+ expected: '{ foo: [Getter: <Inspection threw (null)>] }'
+ },
+ {
+ val: true,
+ expected: '{ foo: [Getter: <Inspection threw (true)>] }'
+ },
+ {
+ val: 1,
+ expected: '{ foo: [Getter: <Inspection threw (1)>] }'
+ },
+ {
+ val: 1n,
+ expected: '{ foo: [Getter: <Inspection threw (1n)>] }'
+ },
+ {
+ val: Symbol(),
+ expected: '{ foo: [Getter: <Inspection threw (Symbol())>] }'
+ },
+ {
+ val: () => {},
+ expected: '{ foo: [Getter: <Inspection threw ([Function: val])>] }'
+ },
+ {
+ val: 'string',
+ expected: "{ foo: [Getter: <Inspection threw ('string')>] }"
+ },
+ {
+ val: [],
+ expected: '{ foo: [Getter: <Inspection threw ([])>] }'
+ },
+ {
+ val: { get message() { return 'Oops'; } },
+ expected: "{ foo: [Getter: <Inspection threw ({ message: [Getter: 'Oops'] })>] }"
+ },
+ {
+ val: Error,
+ expected: '{ foo: [Getter: <Inspection threw ([Function: Error])>] }'
+ },
+].forEach(({ val, expected }) => {
+ assert.strictEqual(
+ inspect({
+ get foo() { throw val; }
+ }, { getters: true }),
+ expected,
+ );
+});
+
// Check compact number mode.
{
let obj = {
@@ -3231,25 +3462,26 @@ assert.strictEqual(
'\x1B[2mdef: \x1B[33m5\x1B[39m\x1B[22m }'
);
- assert.strictEqual(
+ assert.match(
inspect(Object.getPrototypeOf(bar), { showHidden: true, getters: true }),
- '<ref *1> Foo [Map] {\n' +
- ' [constructor]: [class Bar extends Foo] {\n' +
- ' [length]: 0,\n' +
- " [name]: 'Bar',\n" +
- ' [prototype]: [Circular *1],\n' +
- ' [Symbol(Symbol.species)]: [Getter: <Inspection threw ' +
- "(Symbol.prototype.toString requires that 'this' be a Symbol)>]\n" +
- ' },\n' +
- " [xyz]: [Getter: 'YES!'],\n" +
- ' [Symbol(nodejs.util.inspect.custom)]: ' +
- '[Function: [nodejs.util.inspect.custom]] {\n' +
- ' [length]: 0,\n' +
- " [name]: '[nodejs.util.inspect.custom]'\n" +
- ' },\n' +
- ' [abc]: [Getter: true],\n' +
- ' [def]: [Getter/Setter: false]\n' +
- ' }'
+ new RegExp('^' + RegExp.escape(
+ '<ref *1> Foo [Map] {\n' +
+ ' [constructor]: [class Bar extends Foo] {\n' +
+ ' [length]: 0,\n' +
+ " [name]: 'Bar',\n" +
+ ' [prototype]: [Circular *1],\n' +
+ ' [Symbol(Symbol.species)]: [Getter: <Inspection threw ' +
+ "(TypeError: Symbol.prototype.toString requires that 'this' be a Symbol") + '.*' + RegExp.escape(')>]\n' +
+ ' },\n' +
+ " [xyz]: [Getter: 'YES!'],\n" +
+ ' [Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]] {\n' +
+ ' [length]: 0,\n' +
+ " [name]: '[nodejs.util.inspect.custom]'\n" +
+ ' },\n' +
+ ' [abc]: [Getter: true],\n' +
+ ' [def]: [Getter/Setter: false]\n' +
+ '}'
+ ) + '$', 's')
);
assert.strictEqual(