From a90c2520385af2ac6b5b11358876f3fc8cdf0eea Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 12 Mar 2026 13:15:44 +0100 Subject: [PATCH 1/3] lib: prefer primordials in SubtleCrypto --- lib/internal/crypto/cfrg.js | 3 ++- lib/internal/crypto/ml_dsa.js | 3 ++- lib/internal/crypto/ml_kem.js | 3 ++- lib/internal/crypto/util.js | 20 ++++++++++---------- lib/internal/crypto/webidl.js | 12 +++++++----- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index c5bbaae90cf595..fd3f168435ddcb 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -2,6 +2,7 @@ const { SafeSet, + StringPrototypeToLowerCase, } = primordials; const { Buffer } = require('buffer'); @@ -332,7 +333,7 @@ function cfrgImportKey( return undefined; } - if (keyObject.asymmetricKeyType !== name.toLowerCase()) { + if (keyObject.asymmetricKeyType !== StringPrototypeToLowerCase(name)) { throw lazyDOMException('Invalid key type', 'DataError'); } diff --git a/lib/internal/crypto/ml_dsa.js b/lib/internal/crypto/ml_dsa.js index ebe3bfe3d17ca0..f4df51ae7cb6aa 100644 --- a/lib/internal/crypto/ml_dsa.js +++ b/lib/internal/crypto/ml_dsa.js @@ -2,6 +2,7 @@ const { SafeSet, + StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeSet, Uint8Array, @@ -276,7 +277,7 @@ function mlDsaImportKey( return undefined; } - if (keyObject.asymmetricKeyType !== name.toLowerCase()) { + if (keyObject.asymmetricKeyType !== StringPrototypeToLowerCase(name)) { throw lazyDOMException('Invalid key type', 'DataError'); } diff --git a/lib/internal/crypto/ml_kem.js b/lib/internal/crypto/ml_kem.js index f6eb76cef10b20..208913cae86c36 100644 --- a/lib/internal/crypto/ml_kem.js +++ b/lib/internal/crypto/ml_kem.js @@ -3,6 +3,7 @@ const { PromiseWithResolvers, SafeSet, + StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeSet, Uint8Array, @@ -209,7 +210,7 @@ function mlKemImportKey( return undefined; } - if (keyObject.asymmetricKeyType !== name.toLowerCase()) { + if (keyObject.asymmetricKeyType !== StringPrototypeToLowerCase(name)) { throw lazyDOMException('Invalid key type', 'DataError'); } diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 96abe6a04b28e1..ab10c53a655a60 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -15,7 +15,7 @@ const { ObjectEntries, ObjectKeys, ObjectPrototypeHasOwnProperty, - Promise, + PromiseWithResolvers, StringPrototypeToUpperCase, Symbol, TypedArrayPrototypeGetBuffer, @@ -656,15 +656,15 @@ function onDone(resolve, reject, err, result) { } function jobPromise(getJob) { - return new Promise((resolve, reject) => { - try { - const job = getJob(); - job.ondone = FunctionPrototypeBind(onDone, job, resolve, reject); - job.run(); - } catch (err) { - onDone(resolve, reject, err); - } - }); + const { promise, resolve, reject } = PromiseWithResolvers(); + try { + const job = getJob(); + job.ondone = FunctionPrototypeBind(onDone, job, resolve, reject); + job.run(); + } catch (err) { + onDone(resolve, reject, err); + } + return promise; } // In WebCrypto, the publicExponent option in RSA is represented as a diff --git a/lib/internal/crypto/webidl.js b/lib/internal/crypto/webidl.js index e72931e9c83cca..4162d84d426f53 100644 --- a/lib/internal/crypto/webidl.js +++ b/lib/internal/crypto/webidl.js @@ -195,12 +195,14 @@ converters.object = (V, opts) => { const isNonSharedArrayBuffer = isArrayBuffer; +/** + * @param {string | object} V - The hash algorithm identifier (string or object). + * @param {string} label - The dictionary name for the error message. + */ function ensureSHA(V, label) { - if ( - typeof V === 'string' ? - !StringPrototypeStartsWith(StringPrototypeToLowerCase(V), 'sha') : - V.name?.toLowerCase?.().startsWith('sha') === false - ) + const name = typeof V === 'string' ? V : V.name; + if (typeof name !== 'string' || + !StringPrototypeStartsWith(StringPrototypeToLowerCase(name), 'sha')) throw lazyDOMException( `Only SHA hashes are supported in ${label}`, 'NotSupportedError'); } From f9f70b3ce5ea0217bb77c13712db9eb2a0fc25cf Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 12 Mar 2026 12:16:32 +0100 Subject: [PATCH 2/3] test: add WebCrypto Promise.prototype.then pollution regression tests --- ...-webcrypto-promise-prototype-pollution.mjs | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 test/parallel/test-webcrypto-promise-prototype-pollution.mjs diff --git a/test/parallel/test-webcrypto-promise-prototype-pollution.mjs b/test/parallel/test-webcrypto-promise-prototype-pollution.mjs new file mode 100644 index 00000000000000..7b4b489bb41756 --- /dev/null +++ b/test/parallel/test-webcrypto-promise-prototype-pollution.mjs @@ -0,0 +1,131 @@ +import * as common from '../common/index.mjs'; + +if (!common.hasCrypto) common.skip('missing crypto'); + +// WebCrypto subtle methods must not leak intermediate values +// through Promise.prototype.then pollution. +// Regression test for https://github.com/nodejs/node/pull/61492 +// and https://github.com/nodejs/node/issues/59699. + +import * as assert from 'node:assert/strict'; +import { hasOpenSSL } from '../common/crypto.js'; + +const { subtle } = globalThis.crypto; + +const originalThen = Promise.prototype.then; +const intercepted = []; + +// Pollute Promise.prototype.then to record all resolved values. +Promise.prototype.then = function(onFulfilled, ...rest) { + return originalThen.call(this, function(value) { + intercepted.push(value); + return typeof onFulfilled === 'function' ? onFulfilled(value) : value; + }, ...rest); +}; + +async function test(label, fn) { + const result = await fn(); + assert.strictEqual( + intercepted.length, 0, + `Promise.prototype.then was called during ${label}` + ); + return result; +} + +await test('digest', () => + subtle.digest('SHA-256', new Uint8Array([1, 2, 3]))); + +await test('generateKey (AES-CBC)', () => + subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'])); + +await test('generateKey (ECDSA)', () => + subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])); + +const rawKey = globalThis.crypto.getRandomValues(new Uint8Array(32)); + +const importedKey = await test('importKey', () => + subtle.importKey('raw', rawKey, { name: 'AES-CBC', length: 256 }, false, ['encrypt', 'decrypt'])); + +const exportableKey = await test('importKey (extractable)', () => + subtle.importKey('raw', rawKey, { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'])); + +await test('exportKey', () => + subtle.exportKey('raw', exportableKey)); + +const iv = globalThis.crypto.getRandomValues(new Uint8Array(16)); +const plaintext = new TextEncoder().encode('Hello, world!'); + +const ciphertext = await test('encrypt', () => + subtle.encrypt({ name: 'AES-CBC', iv }, importedKey, plaintext)); + +await test('decrypt', () => + subtle.decrypt({ name: 'AES-CBC', iv }, importedKey, ciphertext)); + +const signingKey = await test('generateKey (HMAC)', () => + subtle.generateKey({ name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])); + +const data = new TextEncoder().encode('test data'); + +const signature = await test('sign', () => + subtle.sign('HMAC', signingKey, data)); + +await test('verify', () => + subtle.verify('HMAC', signingKey, signature, data)); + +const pbkdf2Key = await test('importKey (PBKDF2)', () => + subtle.importKey('raw', rawKey, 'PBKDF2', false, ['deriveBits', 'deriveKey'])); + +await test('deriveBits', () => + subtle.deriveBits( + { name: 'PBKDF2', salt: rawKey, iterations: 1000, hash: 'SHA-256' }, + pbkdf2Key, 256)); + +// deriveKey — this was the primary leak reported in the issue +await test('deriveKey', () => + subtle.deriveKey( + { name: 'PBKDF2', salt: rawKey, iterations: 1000, hash: 'SHA-256' }, + pbkdf2Key, + { name: 'AES-CBC', length: 256 }, + true, + ['encrypt', 'decrypt'])); + +const wrappingKey = await test('generateKey (AES-KW)', () => + subtle.generateKey({ name: 'AES-KW', length: 256 }, true, ['wrapKey', 'unwrapKey'])); + +const keyToWrap = await test('generateKey (AES-CBC for wrap)', () => + subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'])); + +const wrapped = await test('wrapKey', () => + subtle.wrapKey('raw', keyToWrap, wrappingKey, 'AES-KW')); + +await test('unwrapKey', () => + subtle.unwrapKey( + 'raw', wrapped, wrappingKey, 'AES-KW', + { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'])); + +const { privateKey } = await test('generateKey (ECDSA for getPublicKey)', () => + subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])); + +await test('getPublicKey', () => + subtle.getPublicKey(privateKey, ['verify'])); + +if (hasOpenSSL(3, 5)) { + const kemPair = await test('generateKey (ML-KEM-768)', () => + subtle.generateKey( + { name: 'ML-KEM-768' }, false, + ['encapsulateKey', 'encapsulateBits', 'decapsulateKey', 'decapsulateBits'])); + + const { ciphertext: ct1 } = await test('encapsulateKey', () => + subtle.encapsulateKey( + { name: 'ML-KEM-768' }, kemPair.publicKey, 'HKDF', false, ['deriveBits'])); + + await test('decapsulateKey', () => + subtle.decapsulateKey( + { name: 'ML-KEM-768' }, kemPair.privateKey, ct1, 'HKDF', false, ['deriveBits'])); + + const { ciphertext: ct2 } = await test('encapsulateBits', () => + subtle.encapsulateBits({ name: 'ML-KEM-768' }, kemPair.publicKey)); + + await test('decapsulateBits', () => + subtle.decapsulateBits({ name: 'ML-KEM-768' }, kemPair.privateKey, ct2)); +} From 1c6d567b0ffe7000c849846c05640e3ba0ed8d6d Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 12 Mar 2026 17:22:17 +0100 Subject: [PATCH 3/3] fixup! test: add WebCrypto Promise.prototype.then pollution regression tests --- ...-webcrypto-promise-prototype-pollution.mjs | 132 +++++++----------- 1 file changed, 48 insertions(+), 84 deletions(-) diff --git a/test/parallel/test-webcrypto-promise-prototype-pollution.mjs b/test/parallel/test-webcrypto-promise-prototype-pollution.mjs index 7b4b489bb41756..b4fbedba5e3242 100644 --- a/test/parallel/test-webcrypto-promise-prototype-pollution.mjs +++ b/test/parallel/test-webcrypto-promise-prototype-pollution.mjs @@ -7,125 +7,89 @@ if (!common.hasCrypto) common.skip('missing crypto'); // Regression test for https://github.com/nodejs/node/pull/61492 // and https://github.com/nodejs/node/issues/59699. -import * as assert from 'node:assert/strict'; import { hasOpenSSL } from '../common/crypto.js'; const { subtle } = globalThis.crypto; -const originalThen = Promise.prototype.then; -const intercepted = []; - -// Pollute Promise.prototype.then to record all resolved values. -Promise.prototype.then = function(onFulfilled, ...rest) { - return originalThen.call(this, function(value) { - intercepted.push(value); - return typeof onFulfilled === 'function' ? onFulfilled(value) : value; - }, ...rest); -}; - -async function test(label, fn) { - const result = await fn(); - assert.strictEqual( - intercepted.length, 0, - `Promise.prototype.then was called during ${label}` - ); - return result; -} +Promise.prototype.then = common.mustNotCall('Promise.prototype.then'); -await test('digest', () => - subtle.digest('SHA-256', new Uint8Array([1, 2, 3]))); +await subtle.digest('SHA-256', new Uint8Array([1, 2, 3])); -await test('generateKey (AES-CBC)', () => - subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'])); +await subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']); -await test('generateKey (ECDSA)', () => - subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])); +await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const rawKey = globalThis.crypto.getRandomValues(new Uint8Array(32)); -const importedKey = await test('importKey', () => - subtle.importKey('raw', rawKey, { name: 'AES-CBC', length: 256 }, false, ['encrypt', 'decrypt'])); +const importedKey = await subtle.importKey( + 'raw', rawKey, { name: 'AES-CBC', length: 256 }, false, ['encrypt', 'decrypt']); -const exportableKey = await test('importKey (extractable)', () => - subtle.importKey('raw', rawKey, { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'])); +const exportableKey = await subtle.importKey( + 'raw', rawKey, { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']); -await test('exportKey', () => - subtle.exportKey('raw', exportableKey)); +await subtle.exportKey('raw', exportableKey); const iv = globalThis.crypto.getRandomValues(new Uint8Array(16)); const plaintext = new TextEncoder().encode('Hello, world!'); -const ciphertext = await test('encrypt', () => - subtle.encrypt({ name: 'AES-CBC', iv }, importedKey, plaintext)); +const ciphertext = await subtle.encrypt({ name: 'AES-CBC', iv }, importedKey, plaintext); -await test('decrypt', () => - subtle.decrypt({ name: 'AES-CBC', iv }, importedKey, ciphertext)); +await subtle.decrypt({ name: 'AES-CBC', iv }, importedKey, ciphertext); -const signingKey = await test('generateKey (HMAC)', () => - subtle.generateKey({ name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])); +const signingKey = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); const data = new TextEncoder().encode('test data'); -const signature = await test('sign', () => - subtle.sign('HMAC', signingKey, data)); +const signature = await subtle.sign('HMAC', signingKey, data); -await test('verify', () => - subtle.verify('HMAC', signingKey, signature, data)); +await subtle.verify('HMAC', signingKey, signature, data); -const pbkdf2Key = await test('importKey (PBKDF2)', () => - subtle.importKey('raw', rawKey, 'PBKDF2', false, ['deriveBits', 'deriveKey'])); +const pbkdf2Key = await subtle.importKey( + 'raw', rawKey, 'PBKDF2', false, ['deriveBits', 'deriveKey']); -await test('deriveBits', () => - subtle.deriveBits( - { name: 'PBKDF2', salt: rawKey, iterations: 1000, hash: 'SHA-256' }, - pbkdf2Key, 256)); +await subtle.deriveBits( + { name: 'PBKDF2', salt: rawKey, iterations: 1000, hash: 'SHA-256' }, + pbkdf2Key, 256); -// deriveKey — this was the primary leak reported in the issue -await test('deriveKey', () => - subtle.deriveKey( - { name: 'PBKDF2', salt: rawKey, iterations: 1000, hash: 'SHA-256' }, - pbkdf2Key, - { name: 'AES-CBC', length: 256 }, - true, - ['encrypt', 'decrypt'])); +await subtle.deriveKey( + { name: 'PBKDF2', salt: rawKey, iterations: 1000, hash: 'SHA-256' }, + pbkdf2Key, + { name: 'AES-CBC', length: 256 }, + true, + ['encrypt', 'decrypt']); -const wrappingKey = await test('generateKey (AES-KW)', () => - subtle.generateKey({ name: 'AES-KW', length: 256 }, true, ['wrapKey', 'unwrapKey'])); +const wrappingKey = await subtle.generateKey( + { name: 'AES-KW', length: 256 }, true, ['wrapKey', 'unwrapKey']); -const keyToWrap = await test('generateKey (AES-CBC for wrap)', () => - subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'])); +const keyToWrap = await subtle.generateKey( + { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']); -const wrapped = await test('wrapKey', () => - subtle.wrapKey('raw', keyToWrap, wrappingKey, 'AES-KW')); +const wrapped = await subtle.wrapKey('raw', keyToWrap, wrappingKey, 'AES-KW'); -await test('unwrapKey', () => - subtle.unwrapKey( - 'raw', wrapped, wrappingKey, 'AES-KW', - { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt'])); +await subtle.unwrapKey( + 'raw', wrapped, wrappingKey, 'AES-KW', + { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']); -const { privateKey } = await test('generateKey (ECDSA for getPublicKey)', () => - subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])); +const { privateKey } = await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); -await test('getPublicKey', () => - subtle.getPublicKey(privateKey, ['verify'])); +await subtle.getPublicKey(privateKey, ['verify']); if (hasOpenSSL(3, 5)) { - const kemPair = await test('generateKey (ML-KEM-768)', () => - subtle.generateKey( - { name: 'ML-KEM-768' }, false, - ['encapsulateKey', 'encapsulateBits', 'decapsulateKey', 'decapsulateBits'])); + const kemPair = await subtle.generateKey( + { name: 'ML-KEM-768' }, false, + ['encapsulateKey', 'encapsulateBits', 'decapsulateKey', 'decapsulateBits']); - const { ciphertext: ct1 } = await test('encapsulateKey', () => - subtle.encapsulateKey( - { name: 'ML-KEM-768' }, kemPair.publicKey, 'HKDF', false, ['deriveBits'])); + const { ciphertext: ct1 } = await subtle.encapsulateKey( + { name: 'ML-KEM-768' }, kemPair.publicKey, 'HKDF', false, ['deriveBits']); - await test('decapsulateKey', () => - subtle.decapsulateKey( - { name: 'ML-KEM-768' }, kemPair.privateKey, ct1, 'HKDF', false, ['deriveBits'])); + await subtle.decapsulateKey( + { name: 'ML-KEM-768' }, kemPair.privateKey, ct1, 'HKDF', false, ['deriveBits']); - const { ciphertext: ct2 } = await test('encapsulateBits', () => - subtle.encapsulateBits({ name: 'ML-KEM-768' }, kemPair.publicKey)); + const { ciphertext: ct2 } = await subtle.encapsulateBits( + { name: 'ML-KEM-768' }, kemPair.publicKey); - await test('decapsulateBits', () => - subtle.decapsulateBits({ name: 'ML-KEM-768' }, kemPair.privateKey, ct2)); + await subtle.decapsulateBits( + { name: 'ML-KEM-768' }, kemPair.privateKey, ct2); }