Skip to content

Keep internal state when cloning a value for an object schema - #3158

Open
yu2971512385-ui wants to merge 1 commit into
hapijs:masterfrom
yu2971512385-ui:fix/object-clone-internal-state
Open

yu2971512385-ui wants to merge 1 commit into
hapijs:masterfrom
yu2971512385-ui:fix/object-clone-internal-state

Conversation

@yu2971512385-ui

Copy link
Copy Markdown

Problem

An object schema with keys clones the value before applying them (lib/types/keys.js):

const proto = Object.getPrototypeOf(value);
const clone = Object.create(proto);
Object.assign(clone, value);

Object.assign() copies own enumerable properties. That is everything a plain object or an array holds, but nothing of what a Date, Map, Set, RegExp, URL or Buffer keeps in internal slots, so the clone comes back with the right prototype and no state — and validation reports success:

const schema = Joi.object({ unused: Joi.any() });

const { error, value } = schema.validate(new Date());
error;             // undefined
value instanceof Date;  // true
value.getTime();   // TypeError: this is not a Date object.
JSON.stringify(value);  // TypeError: this is not a Date object.
value schema.validate(value).value on master
new Date(0) .getTime() throws
new Map([['a', 1]]) .get('a') throws
new Set([1]) .has(1) throws
/ab/g .test('ab') throws
Buffer.from('hi') .toString() throws
new URL('https://x.y/') .toString() throws

Any object schema with at least one key, pattern or rename takes this path — Joi.object() and Joi.object().unknown() do not, which is why this hides so well. The failure is silent: joi accepts the value, and the corruption only surfaces later, when the caller uses the returned object.

Fix

Hand values that are not plain objects or arrays to the shallow clone from hoek, which the same function already uses for the nonEnumerables case, and which knows these types. Plain objects, arrays and class instances keep taking the existing Object.create() path, so the common case is unchanged.

Tests

Two cases in test/types/object.js: one asserting the types above survive validation (plus plain objects, arrays and a class instance with a getter), one asserting the input is still not mutated when a property is stripped. Both fail on master and pass with the change; npx lab is green (1839 tests, no leaks).

One case this does not fix: a plain typed array (new Uint8Array([1, 2])) is still broken, because hoek's clone handles Buffer but not other typed arrays. That looked like a separate fix to make in hoek rather than something to work around here, but say the word if you would rather have it covered.

AI assistance (Claude) was used to find this — by round-tripping schemas through describe()/build() and comparing validation results, which is how the broken clone first showed up — and to draft this description. I verified and tested every line.

🤖 Generated with Claude Code

An object schema with keys clones the value before applying them, with
Object.create() and Object.assign(). That copies own enumerable properties,
which is everything a plain object or an array has, but nothing of what a
Date, Map, Set, RegExp, URL or Buffer keeps in internal slots. The clone
came back with the right prototype and no state, so every method on it
threw, and validation reported no error:

    const schema = Joi.object({ unused: Joi.any() });
    schema.validate(new Date()).value.getTime();
    // TypeError: this is not a Date object.

Hand those values to the shallow clone from hoek, which this function
already uses for the nonEnumerables case. Plain objects and arrays keep
taking the existing path.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant