From ac852375b02066ec0076facef92df50aeb1aafde Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 25 May 2026 09:54:33 +0200 Subject: [PATCH 1/5] crypto: coerce -0 to +0 before native calls Signed-off-by: Filip Skokan --- lib/internal/crypto/cipher.js | 9 +- lib/internal/crypto/diffiehellman.js | 5 +- lib/internal/crypto/hash.js | 9 +- lib/internal/crypto/hkdf.js | 2 + lib/internal/crypto/keygen.js | 32 +++-- lib/internal/crypto/random.js | 8 +- test/parallel/test-crypto-negative-zero.js | 144 +++++++++++++++++++++ 7 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 test/parallel/test-crypto-negative-zero.js diff --git a/lib/internal/crypto/cipher.js b/lib/internal/crypto/cipher.js index 1d397e8a94819c..d622d35d85faf8 100644 --- a/lib/internal/crypto/cipher.js +++ b/lib/internal/crypto/cipher.js @@ -108,7 +108,8 @@ function getUIntOption(options, key) { if (options && (value = options[key]) != null) { if (value >>> 0 !== value) throw new ERR_INVALID_ARG_VALUE(`options.${key}`, value); - return value; + // Coerce -0 to +0. + return value + 0; } return -1; } @@ -256,12 +257,16 @@ const kMinNid = 1; const kMaxNid = 2_147_483_647; function getCipherInfo(nameOrNid, options = {}) { validateObject(options, 'options'); - const { keyLength, ivLength } = options; + let { keyLength, ivLength } = options; if (keyLength !== undefined) { validateUint32(keyLength, 'options.keyLength'); + // Coerce -0 to +0. + keyLength += 0; } if (ivLength !== undefined) { validateUint32(ivLength, 'options.ivLength'); + // Coerce -0 to +0. + ivLength += 0; } const type = typeof nameOrNid; diff --git a/lib/internal/crypto/diffiehellman.js b/lib/internal/crypto/diffiehellman.js index d17b06ef155f76..3bd4bd33156c94 100644 --- a/lib/internal/crypto/diffiehellman.js +++ b/lib/internal/crypto/diffiehellman.js @@ -92,8 +92,11 @@ function DiffieHellman(sizeOrKey, keyEncoding, generator, genEncoding) { // rejected with ERR_OSSL_BN_BITS_TOO_SMALL) by OpenSSL. The glue code // in node_crypto.cc accepts values that are IsInt32() for that reason // and that's why we do that here too. - if (typeof sizeOrKey === 'number') + if (typeof sizeOrKey === 'number') { validateInt32(sizeOrKey, 'sizeOrKey'); + // Coerce -0 to +0. + sizeOrKey += 0; + } if (keyEncoding && !Buffer.isEncoding(keyEncoding) && keyEncoding !== 'buffer') { diff --git a/lib/internal/crypto/hash.js b/lib/internal/crypto/hash.js index 5aec1614cb92e9..d47f7518f06260 100644 --- a/lib/internal/crypto/hash.js +++ b/lib/internal/crypto/hash.js @@ -93,10 +93,13 @@ function Hash(algorithm, options) { const isCopy = algorithm instanceof _Hash; if (!isCopy) validateString(algorithm, 'algorithm'); - const xofLen = typeof options === 'object' && options !== null ? + let xofLen = typeof options === 'object' && options !== null ? options.outputLength : undefined; - if (xofLen !== undefined) + if (xofLen !== undefined) { validateUint32(xofLen, 'options.outputLength'); + // Coerce -0 to +0. + xofLen += 0; + } // Lookup the cached ID from JS land because it's faster than decoding // the string in C++ land. const algorithmId = isCopy ? -1 : getCachedHashId(algorithm); @@ -285,6 +288,8 @@ function hash(algorithm, input, options) { if (outputLength !== undefined) { validateUint32(outputLength, 'outputLength'); + // Coerce -0 to +0. + outputLength += 0; } if (outputLength === undefined) { diff --git a/lib/internal/crypto/hkdf.js b/lib/internal/crypto/hkdf.js index 73b16da6923024..c9b868e23af4e2 100644 --- a/lib/internal/crypto/hkdf.js +++ b/lib/internal/crypto/hkdf.js @@ -58,6 +58,8 @@ const validateParameters = hideStackFrames((hash, key, salt, info, length) => { info = validateByteSource.withoutStackTrace(info, 'info'); validateInteger.withoutStackTrace(length, 'length', 0, kMaxLength); + // Coerce -0 to +0. + length += 0; if (info.byteLength > 1024) { throw new ERR_OUT_OF_RANGE.HideStackFramesError( diff --git a/lib/internal/crypto/keygen.js b/lib/internal/crypto/keygen.js index e6e787c39f512a..41971002441ef5 100644 --- a/lib/internal/crypto/keygen.js +++ b/lib/internal/crypto/keygen.js @@ -219,14 +219,18 @@ function createJob(mode, type, options) { case 'rsa-pss': { validateObject(options, 'options'); - const { modulusLength } = options; + let { modulusLength } = options; validateUint32(modulusLength, 'options.modulusLength'); + // Coerce -0 to +0. + modulusLength += 0; let { publicExponent } = options; if (publicExponent == null) { publicExponent = 0x10001; } else { validateUint32(publicExponent, 'options.publicExponent'); + // Coerce -0 to +0. + publicExponent += 0; } if (type === 'rsa') { @@ -238,12 +242,14 @@ function createJob(mode, type, options) { ...encoding); } - const { - hashAlgorithm, mgf1HashAlgorithm, saltLength, - } = options; + const { hashAlgorithm, mgf1HashAlgorithm } = options; + let { saltLength } = options; - if (saltLength !== undefined) + if (saltLength !== undefined) { validateInt32(saltLength, 'options.saltLength', 0); + // Coerce -0 to +0. + saltLength += 0; + } if (hashAlgorithm !== undefined) validateString(hashAlgorithm, 'options.hashAlgorithm'); if (mgf1HashAlgorithm !== undefined) @@ -284,14 +290,19 @@ function createJob(mode, type, options) { case 'dsa': { validateObject(options, 'options'); - const { modulusLength } = options; + let { modulusLength } = options; validateUint32(modulusLength, 'options.modulusLength'); + // Coerce -0 to +0. + modulusLength += 0; let { divisorLength } = options; if (divisorLength == null) { divisorLength = -1; - } else + } else { validateInt32(divisorLength, 'options.divisorLength', 0); + // Coerce -0 to +0. + divisorLength += 0; + } return new DsaKeyPairGenJob( mode, @@ -321,7 +332,8 @@ function createJob(mode, type, options) { case 'dh': { validateObject(options, 'options'); - const { group, primeLength, prime, generator } = options; + const { group, prime } = options; + let { primeLength, generator } = options; if (group != null) { if (prime != null) throw new ERR_INCOMPATIBLE_OPTION_PAIR('group', 'prime'); @@ -342,6 +354,8 @@ function createJob(mode, type, options) { validateBuffer(prime, 'options.prime'); } else if (primeLength != null) { validateInt32(primeLength, 'options.primeLength', 0); + // Coerce -0 to +0. + primeLength += 0; } else { throw new ERR_MISSING_OPTION( 'At least one of the group, prime, or primeLength options'); @@ -349,6 +363,8 @@ function createJob(mode, type, options) { if (generator != null) { validateInt32(generator, 'options.generator', 0); + // Coerce -0 to +0. + generator += 0; } return new DhKeyPairGenJob( mode, diff --git a/lib/internal/crypto/random.js b/lib/internal/crypto/random.js index c324b2292b2fb8..a75c14fd2a1c4b 100644 --- a/lib/internal/crypto/random.js +++ b/lib/internal/crypto/random.js @@ -603,12 +603,14 @@ function checkPrime(candidate, options = kEmptyObject, callback) { } validateFunction(callback, 'callback'); validateObject(options, 'options'); - const { + let { checks = 0, } = options; // The checks option is unsigned but must fit into a signed C int for OpenSSL. validateInt32(checks, 'options.checks', 0); + // Coerce -0 to +0. + checks += 0; const job = new CheckPrimeJob(kCryptoJobAsync, candidate, checks); job.ondone = callback; @@ -632,12 +634,14 @@ function checkPrimeSync(candidate, options = kEmptyObject) { ); } validateObject(options, 'options'); - const { + let { checks = 0, } = options; // The checks option is unsigned but must fit into a signed C int for OpenSSL. validateInt32(checks, 'options.checks', 0); + // Coerce -0 to +0. + checks += 0; const job = new CheckPrimeJob(kCryptoJobSync, candidate, checks); const { 0: err, 1: result } = job.run(); diff --git a/test/parallel/test-crypto-negative-zero.js b/test/parallel/test-crypto-negative-zero.js new file mode 100644 index 00000000000000..53116da2af05ad --- /dev/null +++ b/test/parallel/test-crypto-negative-zero.js @@ -0,0 +1,144 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const crypto = require('crypto'); + +function getOutcome(fn) { + try { + return { result: fn() }; + } catch (err) { + return { err }; + } +} + +function assertSameOutcome(actual, expected) { + if (expected.err !== undefined) { + assert(actual.err instanceof Error); + assert.strictEqual(actual.err.name, expected.err.name); + assert.strictEqual(actual.err.code, expected.err.code); + assert.strictEqual(actual.err.message, expected.err.message); + } else { + assert.deepStrictEqual(actual.result, expected.result); + } +} + +function assertSameErrorOrSuccess(actual, expected) { + if (expected.err !== undefined) { + assert(actual.err instanceof Error); + assert.strictEqual(actual.err.name, expected.err.name); + assert.strictEqual(actual.err.code, expected.err.code); + assert.strictEqual(actual.err.message, expected.err.message); + } else { + assert.strictEqual(actual.err, undefined); + } +} + +{ + const expected = getOutcome(() => + crypto.hkdfSync('sha256', 'key', 'salt', 'info', 0), + ); + assertSameOutcome( + getOutcome(() => crypto.hkdfSync('sha256', 'key', 'salt', 'info', -0)), + expected, + ); + crypto.hkdf('sha256', 'key', 'salt', 'info', -0, + common.mustCall((err, result) => { + assertSameOutcome({ err, result }, expected); + })); +} + +{ + assert.strictEqual( + crypto.checkPrimeSync(Buffer.from([3]), { checks: -0 }), + true, + ); + crypto.checkPrime(Buffer.from([3]), { checks: -0 }, + common.mustSucceed((result) => { + assert.strictEqual(result, true); + })); +} + +{ + assert.throws(() => crypto.createDiffieHellman(-0, 2), { + name: 'Error', + }); +} + +{ + for (const [type, getOptions] of [ + ['rsa', (zero) => ({ modulusLength: zero })], + ['rsa', (zero) => ({ modulusLength: 512, publicExponent: zero })], + ['rsa-pss', (zero) => ({ + modulusLength: 512, + publicExponent: 65537, + saltLength: zero, + })], + ['dsa', (zero) => ({ modulusLength: zero })], + ['dsa', (zero) => ({ modulusLength: 512, divisorLength: zero })], + ['dh', (zero) => ({ primeLength: zero })], + ['dh', (zero) => ({ primeLength: 2, generator: zero })], + ]) { + assertSameErrorOrSuccess( + getOutcome(() => crypto.generateKeyPairSync(type, getOptions(-0))), + getOutcome(() => crypto.generateKeyPairSync(type, getOptions(0))), + ); + } + + crypto.generateKeyPair('rsa', { modulusLength: -0 }, + common.mustCall((err) => { + assert(err instanceof Error); + })); +} + +if (!process.features.openssl_is_boringssl) { + assert.strictEqual( + crypto.createHash('shake128', { outputLength: -0 }).digest('hex'), + '', + ); + assert.strictEqual( + crypto.createHash('shake128', { outputLength: 5 }) + .copy({ outputLength: -0 }) + .digest('hex'), + '', + ); + assert.strictEqual( + crypto.hash('shake128', 'data', { outputLength: -0 }), + '', + ); +} + +{ + const key = Buffer.alloc(16); + const iv = Buffer.alloc(12); + + assertSameErrorOrSuccess( + getOutcome(() => crypto.createCipheriv( + 'aes-128-gcm', key, iv, { authTagLength: -0 })), + getOutcome(() => crypto.createCipheriv( + 'aes-128-gcm', key, iv, { authTagLength: 0 })), + ); + assertSameErrorOrSuccess( + getOutcome(() => crypto.createCipheriv( + 'aes-128-gcm', key, iv).setAAD( + Buffer.alloc(0), + { plaintextLength: -0 }, + )), + getOutcome(() => crypto.createCipheriv( + 'aes-128-gcm', key, iv).setAAD( + Buffer.alloc(0), + { plaintextLength: 0 }, + )), + ); + assert.strictEqual( + crypto.getCipherInfo('aes-128-cbc', { keyLength: -0 }), + undefined, + ); + assert.strictEqual( + crypto.getCipherInfo('aes-128-cbc', { ivLength: -0 }), + undefined, + ); +} From 7e2ac129c639bb5b37864bd380bb6ba8984b489c Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 25 May 2026 10:23:24 +0200 Subject: [PATCH 2/5] dns: coerce -0 to +0 in lookup and resolver inputs Signed-off-by: Filip Skokan --- lib/dns.js | 9 ++++++--- lib/internal/dns/promises.js | 6 ++++-- lib/internal/dns/utils.js | 8 ++++++-- test/parallel/test-dns-negative-zero.js | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 test/parallel/test-dns-negative-zero.js diff --git a/lib/dns.js b/lib/dns.js index d651f5ea0a2685..1301cbd2ce9901 100644 --- a/lib/dns.js +++ b/lib/dns.js @@ -157,7 +157,8 @@ function lookup(hostname, options, callback) { validateFunction(callback, 'callback'); validateOneOf(options, 'family', validFamilies); - family = options; + // Coerce -0 to +0. + family = options + 0; } else if (options !== undefined && typeof options !== 'object') { validateFunction(arguments.length === 2 ? options : callback, 'callback'); throw new ERR_INVALID_ARG_TYPE('options', ['integer', 'object'], options); @@ -179,7 +180,8 @@ function lookup(hostname, options, callback) { break; default: validateOneOf(options.family, 'options.family', validFamilies); - family = options.family; + // Coerce -0 to +0. + family = options.family + 0; break; } } @@ -274,7 +276,8 @@ function lookupService(address, port, callback) { validateFunction(callback, 'callback'); - port = +port; + // Coerce -0 to +0. + port = +port + 0; const req = new GetNameInfoReqWrap(); req.callback = callback; diff --git a/lib/internal/dns/promises.js b/lib/internal/dns/promises.js index f7ee8fd25423ca..57943e1f658b78 100644 --- a/lib/internal/dns/promises.js +++ b/lib/internal/dns/promises.js @@ -205,7 +205,8 @@ function lookup(hostname, options) { if (typeof options === 'number') { validateOneOf(options, 'family', validFamilies); - family = options; + // Coerce -0 to +0. + family = options + 0; } else if (options !== undefined && typeof options !== 'object') { throw new ERR_INVALID_ARG_TYPE('options', ['integer', 'object'], options); } else { @@ -216,7 +217,8 @@ function lookup(hostname, options) { } if (options?.family != null) { validateOneOf(options.family, 'options.family', validFamilies); - family = options.family; + // Coerce -0 to +0. + family = options.family + 0; } if (options?.all != null) { validateBoolean(options.all, 'options.all'); diff --git a/lib/internal/dns/utils.js b/lib/internal/dns/utils.js index d036c4c7255eab..271731f87c43a2 100644 --- a/lib/internal/dns/utils.js +++ b/lib/internal/dns/utils.js @@ -45,14 +45,18 @@ const { } = require('internal/v8/startup_snapshot'); function validateTimeout(options) { - const { timeout = -1 } = { ...options }; + let { timeout = -1 } = { ...options }; validateInt32(timeout, 'options.timeout', -1); + // Coerce -0 to +0. + timeout += 0; return timeout; } function validateMaxTimeout(options) { - const { maxTimeout = 0 } = { ...options }; + let { maxTimeout = 0 } = { ...options }; validateUint32(maxTimeout, 'options.maxTimeout'); + // Coerce -0 to +0. + maxTimeout += 0; return maxTimeout; } diff --git a/test/parallel/test-dns-negative-zero.js b/test/parallel/test-dns-negative-zero.js new file mode 100644 index 00000000000000..2b07144bc06bf0 --- /dev/null +++ b/test/parallel/test-dns-negative-zero.js @@ -0,0 +1,14 @@ +'use strict'; + +const common = require('../common'); + +const dns = require('dns'); + +dns.lookup('localhost', -0, common.mustCall()); +dns.lookup('localhost', { family: -0 }, common.mustCall()); +dns.lookupService('127.0.0.1', -0, common.mustCall()); + +new dns.Resolver({ timeout: -0 }); +new dns.Resolver({ maxTimeout: -0 }); + +dns.promises.lookup('localhost', { family: -0 }).then(common.mustCall()); From 673d7ecf18b3d4687ffa8a9a43f7f723f746d078 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 25 May 2026 10:23:43 +0200 Subject: [PATCH 3/5] fs: coerce -0 to +0 in mode flags and watch intervals Signed-off-by: Filip Skokan --- lib/internal/fs/utils.js | 3 ++- lib/internal/fs/watchers.js | 2 ++ lib/internal/validators.js | 3 ++- test/parallel/test-fs-negative-zero.js | 33 ++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-fs-negative-zero.js diff --git a/lib/internal/fs/utils.js b/lib/internal/fs/utils.js index 811c52aeffb8b9..d916d003b326a2 100644 --- a/lib/internal/fs/utils.js +++ b/lib/internal/fs/utils.js @@ -724,7 +724,8 @@ function getStatFsFromBinding(stats) { function stringToFlags(flags, name = 'flags') { if (typeof flags === 'number') { validateInt32(flags, name); - return flags; + // Coerce -0 to +0. + return flags + 0; } if (flags == null) { diff --git a/lib/internal/fs/watchers.js b/lib/internal/fs/watchers.js index c540307d79a52d..2dad214a729c9c 100644 --- a/lib/internal/fs/watchers.js +++ b/lib/internal/fs/watchers.js @@ -176,6 +176,8 @@ StatWatcher.prototype[kFSStatWatcherStart] = function(filename, filename = getValidatedPath(filename, 'filename'); validateUint32(interval, 'interval'); + // Coerce -0 to +0. + interval += 0; const err = this._handle.start(toNamespacedPath(filename), interval); if (err) { const error = new UVException({ diff --git a/lib/internal/validators.js b/lib/internal/validators.js index d2add7faa30a9e..db36251e56f602 100644 --- a/lib/internal/validators.js +++ b/lib/internal/validators.js @@ -79,7 +79,8 @@ function parseFileMode(value, name, def) { } validateUint32(value, name); - return value; + // Coerce -0 to +0. + return value + 0; } /** diff --git a/test/parallel/test-fs-negative-zero.js b/test/parallel/test-fs-negative-zero.js new file mode 100644 index 00000000000000..538cea67faaa3c --- /dev/null +++ b/test/parallel/test-fs-negative-zero.js @@ -0,0 +1,33 @@ +'use strict'; + +require('../common'); + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const missing = path.join( + os.tmpdir(), + `node-fs-negative-zero-${process.pid}`, + 'entry', +); + +function ignoreExpectedError(fn) { + try { + fn(); + } catch { + // Ignore expected file system errors from the missing path. + } +} + +const fd = fs.openSync(process.execPath, -0); +fs.closeSync(fd); + +ignoreExpectedError(() => fs.openSync(process.execPath, 'r', -0)); +ignoreExpectedError(() => fs.readFileSync(process.execPath, { flag: -0 })); +ignoreExpectedError(() => fs.mkdirSync(missing, { mode: -0 })); +ignoreExpectedError(() => fs.chmodSync(missing, -0)); +ignoreExpectedError(() => fs.writeFileSync(missing, '', { mode: -0 })); + +fs.watchFile(missing, { interval: -0 }, () => {}); +fs.unwatchFile(missing); From 669332422f94edde8f564ac4178d1e5a8e1e1a1a Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 25 May 2026 10:23:58 +0200 Subject: [PATCH 4/5] net: coerce -0 to +0 in BlockList prefixes Signed-off-by: Filip Skokan --- lib/internal/blocklist.js | 2 ++ test/parallel/test-net-negative-zero.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 test/parallel/test-net-negative-zero.js diff --git a/lib/internal/blocklist.js b/lib/internal/blocklist.js index 552819405a1a60..b0840ed7fa462e 100644 --- a/lib/internal/blocklist.js +++ b/lib/internal/blocklist.js @@ -123,6 +123,8 @@ class BlockList { validateInt32(prefix, 'prefix', 0, 128); break; } + // Coerce -0 to +0. + prefix += 0; this[kHandle].addSubnet(network[kSocketAddressHandle], prefix); } diff --git a/test/parallel/test-net-negative-zero.js b/test/parallel/test-net-negative-zero.js new file mode 100644 index 00000000000000..fa64d1ef4151cc --- /dev/null +++ b/test/parallel/test-net-negative-zero.js @@ -0,0 +1,15 @@ +'use strict'; + +require('../common'); + +const net = require('net'); + +{ + const blockList = new net.BlockList(); + blockList.addSubnet('0.0.0.0', -0); +} + +{ + const blockList = new net.BlockList(); + blockList.addSubnet('::', -0, 'ipv6'); +} From ace7877b777cd5e94cd5b498c77ff038752ee3e1 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 25 May 2026 10:24:15 +0200 Subject: [PATCH 5/5] zlib: coerce -0 to +0 for crc32 seeds Signed-off-by: Filip Skokan --- lib/zlib.js | 2 ++ test/parallel/test-zlib-negative-zero.js | 8 ++++++++ 2 files changed, 10 insertions(+) create mode 100644 test/parallel/test-zlib-negative-zero.js diff --git a/lib/zlib.js b/lib/zlib.js index 056b1a13a17392..d4f2446a5976cb 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -969,6 +969,8 @@ function crc32(data, value = 0) { throw new ERR_INVALID_ARG_TYPE('data', ['Buffer', 'TypedArray', 'DataView', 'string'], data); } validateUint32(value, 'value'); + // Coerce -0 to +0. + value += 0; return crc32Native(data, value); } diff --git a/test/parallel/test-zlib-negative-zero.js b/test/parallel/test-zlib-negative-zero.js new file mode 100644 index 00000000000000..dc9cc3175d23b5 --- /dev/null +++ b/test/parallel/test-zlib-negative-zero.js @@ -0,0 +1,8 @@ +'use strict'; + +require('../common'); + +const assert = require('assert'); +const zlib = require('zlib'); + +assert.strictEqual(zlib.crc32('', -0), zlib.crc32('', 0));