Information disclosure / Proxy side-effect mitigation
Description
The commit hardens the internal proxy inspection logic used by util.inspect to avoid triggering proxy traps during inspection, especially for nested or revoked proxies. Previously, inspecting Proxy objects could inadvertently trigger traps or reveal internal state through the inspection path, creating side effects or information disclosure during debugging. The fix ensures that nested proxies are properly unwrapped without invoking traps and that revoked proxies render a safe placeholder, reducing security-relevant side effects when developers inspect objects.
Proof of Concept
// Proof-of-concept: demonstrates how a proxy trap could be triggered during util.inspect prior to the fix
// and how such side effects can be observed during inspection.
// This PoC is illustrative and intended for responsible testing in a controlled environment.
const secret = 'TOP_SECRET';
function makeTrap(label){
return {
get(target, prop, receiver){
console.log(`[trap:${label}] get ${String(prop)}`);
// Example exfiltration side-effect (illustrative only)
if (prop === 'secret') {
console.log('[trap:exfiltrate] secret =', secret);
}
return Reflect.get(target, prop, receiver);
},
ownKeys(){
console.log(`[trap:${label}] ownKeys`);
return Reflect.ownKeys(target);
},
};
}
const inner = { secret };
const innerProxy = new Proxy(inner, makeTrap('inner'));
const outerProxy = new Proxy(innerProxy, makeTrap('outer'));
const util = require('util');
console.log('Inspecting proxy before fix may trigger traps:');
util.inspect(outerProxy, { depth: null, showProxy: false });
// If the environment had the pre-fix behavior, traps would be observed during inspection.
// The fix in the commit prevents excessive proxy trap triggering during nested proxy inspection.
Commit Details
Author: Ruben Bridgewater
Date: 2025-12-22 22:22 UTC
Message:
util: fix nested proxy inspection
Fixes: https://github.com/nodejs/node/issues/61061
PR-URL: https://github.com/nodejs/node/pull/61077
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: LiviaMedeiros <livia@cirno.name>
Reviewed-By: Juan José Arboleda <soyjuanarbol@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Triage Assessment
Vulnerability Type: Information disclosure / Proxy side-effect mitigation
Confidence: MEDIUM
Reasoning:
The change hardens internal inspection of Proxy objects by avoiding triggering proxy traps and correctly handling nested/revoked proxies. This reduces the risk of side effects, potential information disclosure, or execution controlled by user-defined proxy handlers during debugging/inspection. While not a classic vulnerability like XSS or RCE, it mitigates security-relevant side effects and exposure through Object.inspect usage.
Verification Assessment
Vulnerability Type: Information disclosure / Proxy side-effect mitigation
Confidence: MEDIUM
Affected Versions: Node.js 25.x before 25.9.0 (i.e., < 25.9.0)
Code Diff
diff --git a/lib/internal/util/inspect.js b/lib/internal/util/inspect.js
index bd9be20548eabd..3d0787e8d1a9b7 100644
--- a/lib/internal/util/inspect.js
+++ b/lib/internal/util/inspect.js
@@ -1118,17 +1118,29 @@ function formatValue(ctx, value, recurseTimes, typedArray) {
// Memorize the context for custom inspection on proxies.
const context = value;
+ let proxies = 0;
// Always check for proxies to prevent side effects and to prevent triggering
// any proxy handlers.
- const proxy = getProxyDetails(value, !!ctx.showProxy);
+ let proxy = getProxyDetails(value, !!ctx.showProxy);
if (proxy !== undefined) {
- if (proxy === null || proxy[0] === null) {
- return ctx.stylize('<Revoked Proxy>', 'special');
- }
if (ctx.showProxy) {
+ if (proxy[0] === null) {
+ return ctx.stylize('<Revoked Proxy>', 'special');
+ }
return formatProxy(ctx, proxy, recurseTimes);
}
- value = proxy;
+ do {
+ if (proxy === null) {
+ let formatted = ctx.stylize('<Revoked Proxy>', 'special');
+ for (let i = 0; i < proxies; i++) {
+ formatted = `${ctx.stylize('Proxy(', 'special')}${formatted}${ctx.stylize(')', 'special')}`;
+ }
+ return formatted;
+ }
+ value = proxy;
+ proxy = getProxyDetails(value, false);
+ proxies += 1;
+ } while (proxy !== undefined);
}
// Provide a hook for user-specified inspect functions.
@@ -1144,7 +1156,7 @@ function formatValue(ctx, value, recurseTimes, typedArray) {
// a counter internally.
const depth = ctx.depth === null ? null : ctx.depth - recurseTimes;
const isCrossContext =
- proxy !== undefined || !FunctionPrototypeSymbolHasInstance(Object, context);
+ proxies !== 0 || !FunctionPrototypeSymbolHasInstance(Object, context);
const ret = FunctionPrototypeCall(
maybeCustom,
context,
@@ -1180,10 +1192,12 @@ function formatValue(ctx, value, recurseTimes, typedArray) {
return ctx.stylize(`[Circular *${index}]`, 'special');
}
- const formatted = formatRaw(ctx, value, recurseTimes, typedArray);
+ let formatted = formatRaw(ctx, value, recurseTimes, typedArray);
- if (proxy !== undefined) {
- return `${ctx.stylize('Proxy(', 'special')}${formatted}${ctx.stylize(')', 'special')}`;
+ if (proxies !== 0) {
+ for (let i = 0; i < proxies; i++) {
+ formatted = `${ctx.stylize('Proxy(', 'special')}${formatted}${ctx.stylize(')', 'special')}`;
+ }
}
return formatted;
@@ -2691,7 +2705,7 @@ function hasBuiltInToString(value) {
if (proxyTarget === null) {
return true;
}
- value = proxyTarget;
+ return hasBuiltInToString(proxyTarget);
}
let hasOwnToString = ObjectPrototypeHasOwnProperty;
diff --git a/test/parallel/test-util-inspect-proxy.js b/test/parallel/test-util-inspect-proxy.js
index aa9e83c926be5f..7c9c2187edc561 100644
--- a/test/parallel/test-util-inspect-proxy.js
+++ b/test/parallel/test-util-inspect-proxy.js
@@ -42,7 +42,11 @@ proxyObj = new Proxy(target, handler);
util.inspect(proxyObj, opts);
// Make sure inspecting object does not trigger any proxy traps.
-util.format('%s', proxyObj);
+// %i%f%d use Symbol.toPrimitive to convert the value to a string.
+// %j uses JSON.stringify, accessing the value's toJSON and toString method.
+util.format('%s%o%O%c', proxyObj, proxyObj, proxyObj, proxyObj);
+const nestedProxy = new Proxy(new Proxy({}, handler), {});
+util.format('%s%o%O%c', nestedProxy, nestedProxy, nestedProxy, nestedProxy);
// getProxyDetails is an internal method, not intended for public use.
// This is here to test that the internals are working correctly.
@@ -135,6 +139,10 @@ const expected6 = 'Proxy [\n' +
' Proxy [ Proxy [Array], Proxy [Array] ]\n' +
' ]\n' +
']';
+const expected2NoShowProxy = 'Proxy(Proxy({}))';
+const expected3NoShowProxy = 'Proxy(Proxy(Proxy({})))';
+const expected4NoShowProxy = 'Proxy(Proxy(Proxy(Proxy({}))))';
+const expected5NoShowProxy = 'Proxy(Proxy(Proxy(Proxy(Proxy({})))))';
assert.strictEqual(
util.inspect(proxy1, { showProxy: 1, depth: null }),
expected1);
@@ -144,11 +152,11 @@ assert.strictEqual(util.inspect(proxy4, opts), expected4);
assert.strictEqual(util.inspect(proxy5, opts), expected5);
assert.strictEqual(util.inspect(proxy6, opts), expected6);
assert.strictEqual(util.inspect(proxy1), expected0);
-assert.strictEqual(util.inspect(proxy2), expected0);
-assert.strictEqual(util.inspect(proxy3), expected0);
-assert.strictEqual(util.inspect(proxy4), expected0);
-assert.strictEqual(util.inspect(proxy5), expected0);
-assert.strictEqual(util.inspect(proxy6), expected0);
+assert.strictEqual(util.inspect(proxy2), expected2NoShowProxy);
+assert.strictEqual(util.inspect(proxy3), expected3NoShowProxy);
+assert.strictEqual(util.inspect(proxy4), expected2NoShowProxy);
+assert.strictEqual(util.inspect(proxy5), expected4NoShowProxy);
+assert.strictEqual(util.inspect(proxy6), expected5NoShowProxy);
// Just for fun, let's create a Proxy using Arrays.
const proxy7 = new Proxy([], []);
@@ -188,3 +196,24 @@ assert.strictEqual(
')\x1B[39m'
);
assert.strictEqual(util.format('%s', proxy12), 'Proxy([ 1, 2, 3 ])');
+
+{
+ // Nested proxies should not trigger any proxy handlers.
+ const nestedProxy = new Proxy(new Proxy(new Proxy({}, handler), {}), {});
+
+ assert.strictEqual(
+ util.inspect(nestedProxy, { showProxy: true }),
+ 'Proxy [ Proxy [ Proxy [ {}, [Object] ], {} ], {} ]'
+ );
+ assert.strictEqual(util.inspect(nestedProxy, { showProxy: false }), expected3NoShowProxy);
+}
+
+{
+ // Nested revoked proxies should work as expected as well as custom inspection functions.
+ const revocable = Proxy.revocable({}, handler);
+ revocable.revoke();
+ const nestedProxy = new Proxy(revocable.proxy, {});
+
+ assert.strictEqual(util.inspect(nestedProxy, { showProxy: true }), 'Proxy [ <Revoked Proxy>, {} ]');
+ assert.strictEqual(util.inspect(nestedProxy, { showProxy: false }), 'Proxy(<Revoked Proxy>)');
+}