Skip to content

fix(cast): reject non-numeric object in castDouble instead of storing Double(NaN)#16352

Open
spokodev wants to merge 1 commit into
Automattic:masterfrom
spokodev:fix/castdouble-reject-non-numeric-object
Open

fix(cast): reject non-numeric object in castDouble instead of storing Double(NaN)#16352
spokodev wants to merge 1 commit into
Automattic:masterfrom
spokodev:fix/castdouble-reject-non-numeric-object

Conversation

@spokodev

@spokodev spokodev commented Jul 1, 2026

Copy link
Copy Markdown

Summary

castDouble silently stores Double(NaN) when given a non-numeric object or array instead of throwing, so casting and validation of a Double path pass with no error. The Number, Int32, and BigInt casters all reject the same inputs, so Double is the outlier.

Steps to reproduce

lib/cast/double.js on the object branch:

const castDouble = require('./lib/cast/double');
castDouble({ malicious: 'object' }); // Double { value: NaN } (expected: throw)
castDouble([5, 6]);                   // Double { value: NaN } (expected: throw)

At the schema level, a non-numeric object reaches the stored document and passes validation:

const schema = new Schema({ price: Schema.Types.Double });
const Test = mongoose.model('Test', schema);

const doc = new Test({ price: { x: 1 } });
doc.price;              // Double { value: NaN }
doc.validateSync();     // undefined (no error)

The equivalent Number path rejects the same input:

const schema = new Schema({ price: Number });
const doc = new (mongoose.model('T2', schema))({ price: { x: 1 } });
doc.validateSync().errors.price.name; // 'CastError'

So new Model(req.body) with a body like { price: { any: 1 } } persists NaN on a scalar path with no cast error and no validation error.

Root cause

The typeof val === 'object' branch computes coercedVal = Number(tempVal) and returns new BSON.Double(coercedVal) with no check on the result. For a non-numeric object or array, Number(tempVal) is NaN, which is returned as a valid Double. The branch's own inline comment (// throw an error) shows the throw was intended but never added.

Fix

Add a NaN guard in the object branch using the module's existing assert idiom (SchemaDouble.cast translates a thrown assertion into a CastError), while preserving explicit-NaN inputs that the JSDoc documents as valid:

coercedVal = Number(tempVal);
assert.ok(!Number.isNaN(coercedVal) || Number.isNaN(tempVal));

Plain NaN and an object whose valueOf() returns NaN still cast to Double(NaN); only inputs that become NaN through non-numeric coercion are rejected.

Authority

The castDouble JSDoc states it should "cast it to a IEEE 754-2008 floating point, or throw an Error if the value cannot be casted" and lists null, undefined, and NaN as the valid inputs. lib/cast/number.js, lib/cast/int32.js, and lib/cast/bigint.js reject the same non-numeric objects.

Tests

Two cases added under the existing cast errors block in test/double.test.js: a plain non-numeric object and an object whose valueOf() returns a non-numeric value, both asserting a CastError. Both fail on master (stored Double(NaN), no validation error) and pass with the fix.

  • test/double.test.js: 28 passing
  • test/cast.test.js + test/int32.test.js: 53 passing

… NaN

castDouble coerced a non-numeric object or array with Number() and
returned Double(NaN) without error, so casting and validation of a
Double path silently passed. Number, Int32, and BigInt casters reject
the same inputs. Add a NaN guard using the module's assert idiom, which
SchemaDouble.cast translates to a CastError, while preserving explicit
NaN inputs (plain NaN and valueOf() returning NaN) per the JSDoc.

@vkarpov15 vkarpov15 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can consider this, but it would have to be a breaking change because the documentation for lib/cast/double explicitly indicates that NaN is considered valid. However, given that Number explicitly disallows NaN, I'm inclined to think that we could block NaN for doubles for consistency's sake.

Comment thread lib/cast/double.js
} else {
coercedVal = Number(tempVal);
// ex: { a: 'im an object', valueOf: () => ({}) } // throw an error
assert.ok(!Number.isNaN(coercedVal) || Number.isNaN(tempVal));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the || Number.isNaN(tempVal) here? tempVal isn't passed to BSON.Double(), so this condition doesn't seem relevant.

Comment thread lib/cast/double.js
assert.ok(!Number.isNaN(coercedVal) || Number.isNaN(tempVal));
}
} else {
coercedVal = Number(val);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going to block NaN, we should also block it here.

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.

2 participants