From 4308792d022646754a6448458fb4b8ffc32c0f64 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 7 Aug 2026 20:14:51 +0200 Subject: [PATCH 1/3] lib: harden webidl dictionary member reads Member descriptors are plain object literals that spell out only the members they need, so createDictionaryConverter() reading the optional validator, defaultValue and required members off them resolves through %Object.prototype%. Copy each descriptor once at construction time with every key present. They keep an ordinary prototype because a null-prototype object literal lands in V8 dictionary mode, and dictionaries with no defaults and no required members now skip steps 4.1.5 and 4.1.6. Signed-off-by: Filip Skokan --- lib/internal/webidl.js | 56 +++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/lib/internal/webidl.js b/lib/internal/webidl.js index 13575d4f730d..7f61bae8c21f 100644 --- a/lib/internal/webidl.js +++ b/lib/internal/webidl.js @@ -20,6 +20,7 @@ const { NumberIsNaN, NumberMAX_SAFE_INTEGER, NumberMIN_SAFE_INTEGER, + ObjectPrototypeHasOwnProperty, ObjectPrototypeIsPrototypeOf, SafeArrayIterator, SafeSet, @@ -699,16 +700,47 @@ function createDictionaryConverter( const dictionaries = ArrayIsArray(members[0]) ? members : [members]; const sortedDictionaries = []; + function ownMember(member, key) { + return ObjectPrototypeHasOwnProperty(member, key) ? member[key] : undefined; + } + + // Dictionaries with no defaults and no required members skip steps + // 4.1.5/4.1.6 entirely, keeping the absent-member path free. + let anyMissingMemberHandling = false; + // Web IDL dictionary conversion steps 3-4 process inherited dictionaries // from least-derived to most-derived and sort only within each dictionary. // Callers with inheritance pass one member array per dictionary level. for (let i = 0; i < dictionaries.length; i++) { - ArrayPrototypePush( - sortedDictionaries, - ArrayPrototypeToSorted(dictionaries[i], compareMembers), + const sortedMembers = ArrayPrototypeToSorted( + dictionaries[i], + compareMembers, ); + // Definition sites spell out only the members they need, so reading the + // optional ones below would resolve through %Object.prototype%. + // Re-materialize each descriptor once with every key present, copied from + // own properties only. The ordinary prototype is deliberate: nothing + // consults it now, and detaching it measurably slows these reads down. + for (let j = 0; j < sortedMembers.length; j++) { + const member = sortedMembers[j]; + const defaultValue = ownMember(member, 'defaultValue'); + const required = ownMember(member, 'required'); + if (typeof defaultValue === 'function' || required) { + anyMissingMemberHandling = true; + } + sortedMembers[j] = { + key: ownMember(member, 'key'), + converter: ownMember(member, 'converter'), + defaultValue, + required, + validator: ownMember(member, 'validator'), + }; + } + ArrayPrototypePush(sortedDictionaries, sortedMembers); } + const hasMissingMemberHandling = anyMissingMemberHandling; + return function(jsDict, options = kEmptyObject) { // Step 1: reject non-object, non-null, non-undefined values. if (jsDict != null && type(jsDict) !== 'Object') { @@ -747,14 +779,16 @@ function createDictionaryConverter( member.validator?.(idlMemberValue, jsDict); // Step 4.1.4.2: set idlDict[key] to the IDL value. idlDict[key] = idlMemberValue; - } else if (typeof member.defaultValue === 'function') { - // Step 4.1.5: store the member default value. - idlDict[key] = member.defaultValue(); - } else if (member.required) { - // Step 4.1.6: required missing members throw. - throw makeException( - missingDictionaryMemberMessage(dictionaryName, key), - makeOptions(options, options.context, 'ERR_MISSING_OPTION')); + } else if (hasMissingMemberHandling) { + if (typeof member.defaultValue === 'function') { + // Step 4.1.5: store the member default value. + idlDict[key] = member.defaultValue(); + } else if (member.required) { + // Step 4.1.6: required missing members throw. + throw makeException( + missingDictionaryMemberMessage(dictionaryName, key), + makeOptions(options, options.context, 'ERR_MISSING_OPTION')); + } } } } From c10b8e8bfa1e630b134959cfb0a0de988a3ef9da Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 7 Aug 2026 20:14:51 +0200 Subject: [PATCH 2/3] crypto: read WebCrypto inputs through primordials BufferSource conversion hands over the caller's own object uncopied, so byteLength, byteOffset, buffer and length reads on it run user-replaceable prototype accessors. Internal lookup tables are indexed with computed keys, so a polluted %Object.prototype% key answers a miss. The %Set% constructor iterates its argument through the user-mutable %Array.prototype% iterator. The algorithm registry and the hash name tables are detached from %Object.prototype% after construction rather than declared `__proto__: null`, which V8 places in dictionary mode. Signed-off-by: Filip Skokan --- lib/internal/crypto/aes.js | 3 +- lib/internal/crypto/cfrg.js | 4 +- lib/internal/crypto/diffiehellman.js | 3 +- lib/internal/crypto/ec.js | 7 +- lib/internal/crypto/hash.js | 5 +- lib/internal/crypto/hashnames.js | 14 +- lib/internal/crypto/keys.js | 4 +- lib/internal/crypto/ml_dsa.js | 7 +- lib/internal/crypto/ml_kem.js | 7 +- lib/internal/crypto/rsa.js | 6 +- lib/internal/crypto/util.js | 90 +++- lib/internal/crypto/webcrypto.js | 13 +- lib/internal/crypto/webcrypto_util.js | 13 +- lib/internal/crypto/webidl.js | 42 +- .../test-webcrypto-prototype-pollution.mjs | 467 ++++++++++++++++++ 15 files changed, 616 insertions(+), 69 deletions(-) create mode 100644 test/parallel/test-webcrypto-prototype-pollution.mjs diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index 7ce1dabbf7c5..3280ddff8459 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -24,6 +24,7 @@ const { const { getUsagesMask, jobPromise, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -218,7 +219,7 @@ function aesImportKey( if (format === 'raw' && name === 'AES-OCB') { return undefined; } - length = keyData.byteLength * 8; + length = getBufferSourceByteLength(keyData) * 8; validateKeyLength(length); handle = importSecretKey(keyData); break; diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index 994b8f510c49..9eea26aa36a0 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, } = primordials; @@ -27,6 +26,7 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -124,7 +124,7 @@ function cfrgImportKey( const { name } = algorithm; let handle; const allowedUsages = kUsages[name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( diff --git a/lib/internal/crypto/diffiehellman.js b/lib/internal/crypto/diffiehellman.js index edf429fc0281..5bf9d5115427 100644 --- a/lib/internal/crypto/diffiehellman.js +++ b/lib/internal/crypto/diffiehellman.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayBufferPrototypeGetByteLength, ArrayBufferPrototypeSlice, FunctionPrototypeCall, ObjectDefineProperty, @@ -373,7 +374,7 @@ function ecdhDeriveBits(algorithm, baseKey, length) { return jobPromiseThen(bits, (bits) => { const sliceLength = numBitsToBytes(length); - const { byteLength } = bits; + const byteLength = ArrayBufferPrototypeGetByteLength(bits); // If the length is larger than the derived secret, throw. if (byteLength < sliceLength) throw lazyDOMException('derived bit length is too small', 'OperationError'); diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index fc19e7cce6a5..cbd89dd11c7e 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, } = primordials; @@ -33,6 +32,7 @@ const { jobPromise, normalizeHashName, kNamedCurveAliases, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -142,7 +142,7 @@ function ecImportKey( let handle; const allowedUsages = kUsages[name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -215,7 +215,8 @@ function ecImportKey( throw lazyDOMException('Invalid keyData', 'DataError'); } - if (kNamedCurveAliases[namedCurve] !== handle.keyDetail({}).namedCurve) + if (kNamedCurveAliases[namedCurve] !== + handle.keyDetail({ __proto__: null }).namedCurve) throw lazyDOMException('Named curve mismatch', 'DataError'); return new InternalCryptoKey( diff --git a/lib/internal/crypto/hash.js b/lib/internal/crypto/hash.js index 16834f169a5b..2445a3f74885 100644 --- a/lib/internal/crypto/hash.js +++ b/lib/internal/crypto/hash.js @@ -30,6 +30,7 @@ const { kHandle, getCachedHashId, getHashCache, + getOptionalByteLength, } = require('internal/crypto/util'); const { @@ -217,8 +218,8 @@ function asyncDigest(algorithm, data) { // Fall through case 'cSHAKE256': { const outputLength = algorithm.outputLength; - if (algorithm.functionName?.byteLength || - algorithm.customization?.byteLength) { + if (getOptionalByteLength(algorithm.functionName) || + getOptionalByteLength(algorithm.customization)) { if (CShakeJob === undefined) { throw lazyDOMException( 'Non-empty CShakeParams functionName or customization is not supported', diff --git a/lib/internal/crypto/hashnames.js b/lib/internal/crypto/hashnames.js index 7a625c47e2f4..c39d696a8011 100644 --- a/lib/internal/crypto/hashnames.js +++ b/lib/internal/crypto/hashnames.js @@ -2,6 +2,7 @@ const { ObjectKeys, + ObjectSetPrototypeOf, } = primordials; const kHashContextNode = 1; @@ -71,15 +72,22 @@ const kHashNames = { }, }; +// Both tables are indexed with computed keys, so a polluted %Object.prototype% +// key must not answer a miss. Detached here rather than declared +// `__proto__: null`: V8 puts that literal form in dictionary mode. +ObjectSetPrototypeOf(kHashNames, null); + { // Index the aliases const keys = ObjectKeys(kHashNames); for (let n = 0; n < keys.length; n++) { - const contexts = ObjectKeys(kHashNames[keys[n]]); + const entry = kHashNames[keys[n]]; + ObjectSetPrototypeOf(entry, null); + const contexts = ObjectKeys(entry); for (let i = 0; i < contexts.length; i++) { - const alias = kHashNames[keys[n]][contexts[i]]; + const alias = entry[contexts[i]]; if (kHashNames[alias] === undefined) - kHashNames[alias] = kHashNames[keys[n]]; + kHashNames[alias] = entry; } } } diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 45d7cd535013..327a6352d5c9 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -5,7 +5,6 @@ const { ObjectDefineProperties, ObjectPrototypeHasOwnProperty, ObjectSetPrototypeOf, - SafeSet, StringPrototypeIncludes, StringPrototypeStartsWith, SymbolToStringTag, @@ -68,6 +67,7 @@ const { getUsagesMask, getUsagesFromMask, hasUsage, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -1306,7 +1306,7 @@ function importGenericSecretKey( extractable, keyUsages, ) { - const usagesSet = new SafeSet(keyUsages); + const usagesSet = toUsagesSet(keyUsages); const { name } = algorithm; if (extractable) throw lazyDOMException(`${name} keys are not extractable`, 'SyntaxError'); diff --git a/lib/internal/crypto/ml_dsa.js b/lib/internal/crypto/ml_dsa.js index 857961ff6ef3..0756198312c0 100644 --- a/lib/internal/crypto/ml_dsa.js +++ b/lib/internal/crypto/ml_dsa.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, @@ -28,6 +27,8 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -122,7 +123,7 @@ function mlDsaImportKey( const { name } = algorithm; let handle; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -147,7 +148,7 @@ function mlDsaImportKey( 'ML-DSA-65': 4060, 'ML-DSA-87': 4924, }; - if (keyData.byteLength === privOnlyLengths[name]) { + if (getBufferSourceByteLength(keyData) === privOnlyLengths[name]) { throw lazyDOMException( 'Importing an ML-DSA PKCS#8 key without a seed is not supported', 'NotSupportedError'); diff --git a/lib/internal/crypto/ml_kem.js b/lib/internal/crypto/ml_kem.js index f18dcd13db77..c917c88c0f29 100644 --- a/lib/internal/crypto/ml_kem.js +++ b/lib/internal/crypto/ml_kem.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, @@ -27,6 +26,8 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -123,7 +124,7 @@ function mlKemImportKey( const { name } = algorithm; let handle; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -148,7 +149,7 @@ function mlKemImportKey( 'ML-KEM-768': 2428, 'ML-KEM-1024': 3196, }; - if (keyData.byteLength === privOnlyLengths[name]) { + if (getBufferSourceByteLength(keyData) === privOnlyLengths[name]) { throw lazyDOMException( 'Importing an ML-KEM PKCS#8 key without a seed is not supported', 'NotSupportedError'); diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index a2757384f4f4..d153ab664bd8 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -2,7 +2,6 @@ const { MathCeil, - SafeSet, TypedArrayPrototypeGetBuffer, Uint8Array, } = primordials; @@ -35,6 +34,7 @@ const { jobPromise, normalizeHashName, validateMaxBufferLength, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -174,7 +174,7 @@ function rsaImportKey( extractable, usages) { const allowedUsages = kUsages[algorithm.name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); let handle; switch (format) { case 'KeyObjectHandle': @@ -234,7 +234,7 @@ function rsaImportKey( const { modulusLength, publicExponent, - } = handle.keyDetail({}); + } = handle.keyDetail({ __proto__: null }); return new InternalCryptoKey(handle, { name: algorithm.name, diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 68981c6dfe05..e33960e898ae 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -15,15 +15,18 @@ const { ObjectEntries, ObjectKeys, ObjectPrototypeHasOwnProperty, + ObjectSetPrototypeOf, PromisePrototypeThen, PromiseReject, PromiseWithResolvers, SafeMap, + SafeSet, StringPrototypeToUpperCase, Symbol, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, TypedArrayPrototypeGetByteOffset, + TypedArrayPrototypeGetLength, TypedArrayPrototypeSlice, Uint8Array, } = primordials; @@ -467,17 +470,23 @@ const experimentalAlgorithms = [ // Also builds a parallel Map per operation // for O(1) case-insensitive algorithm name lookup in normalizeAlgorithm. function createSupportedAlgorithms(algorithmDefs) { + // Detached below rather than declared `__proto__: null`: V8 puts that + // literal form in dictionary mode, slowing every registry lookup. const result = {}; const nameMap = {}; - for (const { 0: algorithmName, 1: operations } of ObjectEntries(algorithmDefs)) { + const algorithmEntries = ObjectEntries(algorithmDefs); + for (let i = 0; i < algorithmEntries.length; i++) { + const { 0: algorithmName, 1: operations } = algorithmEntries[i]; // Skip algorithms that are conditionally not supported if (ObjectPrototypeHasOwnProperty(conditionalAlgorithms, algorithmName) && !conditionalAlgorithms[algorithmName]) { continue; } - for (const { 0: operation, 1: dict } of ObjectEntries(operations)) { + const operationEntries = ObjectEntries(operations); + for (let j = 0; j < operationEntries.length; j++) { + const { 0: operation, 1: dict } = operationEntries[j]; result[operation] ||= {}; nameMap[operation] ||= new SafeMap(); nameMap[operation].set(StringPrototypeToUpperCase(algorithmName), algorithmName); @@ -498,6 +507,13 @@ function createSupportedAlgorithms(algorithmDefs) { } } + const operations = ObjectKeys(result); + for (let i = 0; i < operations.length; i++) { + ObjectSetPrototypeOf(result[operations[i]], null); + } + ObjectSetPrototypeOf(result, null); + ObjectSetPrototypeOf(nameMap, null); + return { algorithms: result, nameMap }; } @@ -546,12 +562,17 @@ const simpleAlgorithmDictionaries = { // Pre-compute ObjectKeys() for each dictionary entry at module init // to avoid allocating a new keys array on every normalizeAlgorithm call. -for (const { 0: name, 1: types } of ObjectEntries(simpleAlgorithmDictionaries)) { +const simpleAlgorithmDictionaryEntries = + ObjectEntries(simpleAlgorithmDictionaries); +for (let i = 0; i < simpleAlgorithmDictionaryEntries.length; i++) { + const { 0: name, 1: types } = simpleAlgorithmDictionaryEntries[i]; simpleAlgorithmDictionaries[name] = { keys: ObjectKeys(types), types }; } +// See createSupportedAlgorithms() for why this is detached here. +ObjectSetPrototypeOf(simpleAlgorithmDictionaries, null); function validateMaxBufferLength(data, name, max = kMaxBufferLength) { - if (data.byteLength > max) { + if (getBufferSourceByteLength(data) > max) { throw lazyDOMException( `${name} must be at most ${max} bytes`, 'OperationError'); @@ -656,20 +677,8 @@ function normalizeAlgorithm(algorithm, op) { const idlValue = normalizedAlgorithm[member]; // 3. if (idlType === 'BufferSource' && idlValue) { - const isView = ArrayBufferIsView(idlValue); - const idlValueBytes = isView ? - new Uint8Array( - getDataViewOrTypedArrayBuffer(idlValue), - getDataViewOrTypedArrayByteOffset(idlValue), - getDataViewOrTypedArrayByteLength(idlValue), - ) : - new Uint8Array( - idlValue, - 0, - ArrayBufferPrototypeGetByteLength(idlValue), - ); normalizedAlgorithm[member] = TypedArrayPrototypeSlice( - idlValueBytes, + getBufferSourceBytes(idlValue), ); } else if (idlType === 'HashAlgorithmIdentifier') { normalizedAlgorithm[member] = normalizeAlgorithm(idlValue, 'digest'); @@ -698,6 +707,26 @@ function getDataViewOrTypedArrayByteLength(V) { DataViewPrototypeGetByteLength(V) : TypedArrayPrototypeGetByteLength(V); } +function getBufferSourceByteLength(V) { + return ArrayBufferIsView(V) ? + getDataViewOrTypedArrayByteLength(V) : + ArrayBufferPrototypeGetByteLength(V); +} + +function getBufferSourceBytes(V) { + return ArrayBufferIsView(V) ? + new Uint8Array( + getDataViewOrTypedArrayBuffer(V), + getDataViewOrTypedArrayByteOffset(V), + getDataViewOrTypedArrayByteLength(V), + ) : + new Uint8Array(V, 0, ArrayBufferPrototypeGetByteLength(V)); +} + +function getOptionalByteLength(V) { + return V === undefined ? 0 : TypedArrayPrototypeGetByteLength(V); +} + function hasAnyNotIn(set, checks) { for (const s of set) if (!ArrayPrototypeIncludes(checks, s)) @@ -856,9 +885,10 @@ function jobPromiseThen(promise, onFulfilled, onRejected) { // Returns undefined if the conversion was unsuccessful. function bigIntArrayToUnsignedInt(input) { let result = 0; + const length = TypedArrayPrototypeGetLength(input); - for (let n = 0; n < input.length; ++n) { - const n_reversed = input.length - n - 1; + for (let n = 0; n < length; ++n) { + const n_reversed = length - n - 1; if (n_reversed >= 4 && input[n]) return; // Too large result |= input[n] << 8 * n_reversed; @@ -869,9 +899,10 @@ function bigIntArrayToUnsignedInt(input) { function bigIntArrayToUnsignedBigInt(input) { let result = 0n; + const length = TypedArrayPrototypeGetLength(input); - for (let n = 0; n < input.length; ++n) { - const n_reversed = input.length - n - 1; + for (let n = 0; n < length; ++n) { + const n_reversed = length - n - 1; result |= BigInt(input[n]) << 8n * BigInt(n_reversed); } @@ -911,6 +942,19 @@ for (let n = 0; n < kCanonicalUsageOrder.length; n++) { kUsageByMask[mask] = usage; } +/** + * Collects a key usage list into a set. + * @param {string[]} usages + * @returns {SafeSet} + */ +function toUsagesSet(usages) { + const usagesSet = new SafeSet(); + for (let n = 0; n < usages.length; n++) { + usagesSet.add(usages[n]); + } + return usagesSet; +} + /** * Returns a bit mask representing the usages from `usageSet`. * @param {SafeSet} usageSet @@ -1039,10 +1083,13 @@ function secureHeapUsed() { module.exports = { getArrayBufferOrView, + getBufferSourceByteLength, + getBufferSourceBytes, getCiphers, getCurves, getDataViewOrTypedArrayBuffer, getHashes, + getOptionalByteLength, emitOpenSSLEngineDeprecation, kHandle, setEngine, @@ -1069,6 +1116,7 @@ module.exports = { getStringOption, getUsagesMask, getUsagesFromMask, + toUsagesSet, hasUsage, secureHeapUsed, getCachedHashId, diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 4326ae3a68db..277a0d7a1fb4 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -69,6 +69,7 @@ const { numBitsToBytes, prepareWebCryptoResult, validateMaxBufferLength, + getOptionalByteLength, } = require('internal/crypto/util'); const { @@ -130,9 +131,17 @@ function prepareSubtleMethod(receiver, method, argLength, required) { } function convertSubtleArgument(prefix, converter, value, index) { + // Mirrors makeOptions() in internal/webidl, including why it stays an + // ordinary literal: the converters read every member below, and an absent + // one would resolve through %Object.prototype%. return webidl.converters[converter](value, { prefix, context: kArgumentContexts[index], + code: undefined, + enforceRange: undefined, + clamp: undefined, + allowShared: undefined, + allowResizable: undefined, }); } @@ -1798,8 +1807,8 @@ function check(op, alg, length) { case 'digest': { if ((normalizedAlgorithm.name === 'cSHAKE128' || normalizedAlgorithm.name === 'cSHAKE256') && - (normalizedAlgorithm.functionName?.byteLength || - normalizedAlgorithm.customization?.byteLength)) { + (getOptionalByteLength(normalizedAlgorithm.functionName) || + getOptionalByteLength(normalizedAlgorithm.customization))) { return CShakeJob !== undefined; } return true; diff --git a/lib/internal/crypto/webcrypto_util.js b/lib/internal/crypto/webcrypto_util.js index 1b802a6dc466..065520c59cb3 100644 --- a/lib/internal/crypto/webcrypto_util.js +++ b/lib/internal/crypto/webcrypto_util.js @@ -1,7 +1,7 @@ 'use strict'; const { - ArrayPrototypePush, + ArrayPrototypePushApply, SafeSet, } = primordials; @@ -19,6 +19,7 @@ const { const { hasAnyNotIn, validateKeyOps, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -60,7 +61,7 @@ function verifyAcceptableKeyUse(subject, usagesSet, allowed) { * @returns {SafeSet} */ function validateKeyUsages(usages, allowed, subject) { - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); verifyAcceptableKeyUse(subject, usagesSet, allowed); return usagesSet; } @@ -115,12 +116,8 @@ function getKeyPairUsages(usagesSet, allowed) { */ function createKeyUsages(publicUsages, privateUsages) { const keygen = []; - for (let n = 0; n < publicUsages.length; n++) { - ArrayPrototypePush(keygen, publicUsages[n]); - } - for (let n = 0; n < privateUsages.length; n++) { - ArrayPrototypePush(keygen, privateUsages[n]); - } + ArrayPrototypePushApply(keygen, publicUsages); + ArrayPrototypePushApply(keygen, privateUsages); return { __proto__: null, public: publicUsages, diff --git a/lib/internal/crypto/webidl.js b/lib/internal/crypto/webidl.js index d0d6036a1ab3..cab4a4c7631d 100644 --- a/lib/internal/crypto/webidl.js +++ b/lib/internal/crypto/webidl.js @@ -1,15 +1,15 @@ 'use strict'; const { - ArrayBufferIsView, ArrayPrototypeIncludes, MathPow, NumberParseInt, ObjectPrototypeHasOwnProperty, StringPrototypeCharCodeAt, + StringPrototypeSplit, StringPrototypeStartsWith, StringPrototypeToLowerCase, - Uint8Array, + TypedArrayPrototypeGetLength, } = primordials; const { @@ -25,6 +25,8 @@ const { } = require('internal/crypto/keys'); const { validateMaxBufferLength, + getBufferSourceByteLength, + getBufferSourceBytes, kNamedCurveAliases, numBitsToBytes, } = require('internal/crypto/util'); @@ -39,7 +41,7 @@ const { } = require('internal/webidl'); function validateByteLength(buf, name, target) { - if (buf.byteLength !== target) { + if (getBufferSourceByteLength(buf) !== target) { throw lazyDOMException( `${name} must contain exactly ${target} bytes`, 'OperationError'); @@ -125,6 +127,9 @@ function enforceRangeOptions(opts) { context: opts.context, code: opts.code, enforceRange: true, + clamp: undefined, + allowShared: undefined, + allowResizable: undefined, }; } @@ -232,7 +237,7 @@ converters.AesKeyGenParams = createDictionaryConverter( function validateZeroLength(parameterName) { return (V, dict) => { - if (V.byteLength) { + if (getBufferSourceByteLength(V)) { throw lazyDOMException( `Non zero-length ${parameterName} is not supported.`, 'NotSupportedError'); } @@ -248,19 +253,18 @@ function validateCShakeOutputLength(V) { } function bufferSourceEqualsAscii(V, string) { - if (V.byteLength !== string.length) return false; + if (getBufferSourceByteLength(V) !== string.length) return false; - const bytes = ArrayBufferIsView(V) ? - new Uint8Array(V.buffer, V.byteOffset, V.byteLength) : - new Uint8Array(V); - for (let i = 0; i < bytes.length; i++) { + const bytes = getBufferSourceBytes(V); + const length = TypedArrayPrototypeGetLength(bytes); + for (let i = 0; i < length; i++) { if (bytes[i] !== StringPrototypeCharCodeAt(string, i)) return false; } return true; } function validateCShakeFunctionName(V) { - if (V.byteLength === 0 || + if (getBufferSourceByteLength(V) === 0 || bufferSourceEqualsAscii(V, 'KMAC') || bufferSourceEqualsAscii(V, 'TupleHash') || bufferSourceEqualsAscii(V, 'ParallelHash')) { @@ -316,7 +320,12 @@ function validateHmacKeyLength(parameterName, zeroError) { }; } -for (const { 0: name, 1: zeroError } of [['HmacKeyGenParams', 'OperationError'], ['HmacImportParams', 'DataError']]) { +const kHmacDictionaries = [ + ['HmacKeyGenParams', 'OperationError'], + ['HmacImportParams', 'DataError'], +]; +for (let i = 0; i < kHmacDictionaries.length; i++) { + const { 0: name, 1: zeroError } = kHmacDictionaries[i]; converters[name] = createDictionaryConverter( name, [ dictAlgorithm, @@ -500,7 +509,7 @@ converters.AeadParams = createDictionaryConverter( validateMaxBufferLength(V, 'algorithm.iv'); break; case 'aes-ocb': - if (V.byteLength > 15) { + if (getBufferSourceByteLength(V) > 15) { throw lazyDOMException( 'AES-OCB algorithm.iv must be no more than 15 bytes', 'OperationError'); @@ -609,7 +618,8 @@ converters.ContextParams = createDictionaryConverter( if (process.features.openssl_is_boringssl) { this.validator = undefined; } else { - let { 0: major, 1: minor } = process.versions.openssl.split('.'); + let { 0: major, 1: minor } = + StringPrototypeSplit(process.versions.openssl, '.'); major = NumberParseInt(major, 10); minor = NumberParseInt(minor, 10); if (major > 3 || (major === 3 && minor >= 2)) { @@ -632,7 +642,7 @@ converters.Argon2Params = createDictionaryConverter( key: 'nonce', converter: converters.BufferSource, validator: (V) => { - if (V.byteLength < 8) { + if (getBufferSourceByteLength(V) < 8) { throw lazyDOMException('nonce must be at least 8 bytes', 'OperationError'); } }, @@ -698,7 +708,9 @@ converters.Argon2Params = createDictionaryConverter( ], ]); -for (const name of ['KmacKeyGenParams', 'KmacImportParams']) { +const kKmacDictionaries = ['KmacKeyGenParams', 'KmacImportParams']; +for (let i = 0; i < kKmacDictionaries.length; i++) { + const name = kKmacDictionaries[i]; converters[name] = createDictionaryConverter( name, [ dictAlgorithm, diff --git a/test/parallel/test-webcrypto-prototype-pollution.mjs b/test/parallel/test-webcrypto-prototype-pollution.mjs new file mode 100644 index 000000000000..1f82a84f1d41 --- /dev/null +++ b/test/parallel/test-webcrypto-prototype-pollution.mjs @@ -0,0 +1,467 @@ +// Flags: --expose-internals + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; + +if (!common.hasCrypto) common.skip('missing crypto'); + +// Regression tests for prototype pollution reaching WebCrypto input validation +// and normalization, via BufferSource prototype accessors, inherited +// %Object.prototype% keys, or %Array.prototype%[%Symbol.iterator%]. See +// test-webcrypto-promise-prototype-pollution.mjs for the promise side. + +const require = createRequire(import.meta.url); +const { kSupportedAlgorithms } = require('internal/crypto/util'); +const { getFips } = require('node:crypto'); +const { subtle } = globalThis.crypto; + +const TypedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); +const data = new TextEncoder().encode('prototype pollution'); +const modulusLength = getFips() === 1 ? 2048 : 1024; + +// Avoids SubtleCrypto.supports(), which warns and invokes the registry's +// experimental-algorithm getters. +function supports(operation, name) { + return Object.hasOwn(kSupportedAlgorithms[operation] ?? {}, name); +} + +// Each poison is { target, key, ...descriptor }. +async function withPoisoned(poisons, fn) { + const saved = []; + for (const { target, key, ...descriptor } of poisons) { + saved.push([target, key, Object.getOwnPropertyDescriptor(target, key)]); + Object.defineProperty(target, key, { + __proto__: null, + configurable: true, + ...descriptor, + }); + } + try { + return await fn(); + } finally { + for (let i = saved.length - 1; i >= 0; i--) { + const { 0: target, 1: key, 2: descriptor } = saved[i]; + if (descriptor === undefined) { + delete target[key]; + } else { + Object.defineProperty(target, key, descriptor); + } + } + } +} + +function poisonTypedArrayByteLength(value) { + return [{ target: TypedArrayPrototype, key: 'byteLength', get: () => value }]; +} + +function inherited(key, value) { + return [{ target: Object.prototype, key, value, writable: true }]; +} + +const poisonArrayIterator = [{ + target: Array.prototype, + key: Symbol.iterator, + value: () => ({ next: () => ({ done: true, value: undefined }) }), + writable: true, +}]; + +// A poisoned array iterator breaks assert too, so settle under the poison and +// assert once it has been restored. +async function settleUnderPoison(poisons, fn) { + const outcome = { __proto__: null, value: undefined, error: undefined }; + await withPoisoned(poisons, async () => { + try { + outcome.value = await fn(); + } catch (err) { + outcome.error = err; + } + }); + return outcome; +} + +// validateByteLength(). Unguarded, the empty iv reaches OpenSSL, which also +// fails with OperationError, hence the message assertion. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(16), 'AES-CBC', false, ['encrypt']); + await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(() => + assert.rejects( + subtle.encrypt({ name: 'AES-CBC', iv: new Uint8Array(0) }, key, data), + { + name: 'OperationError', + message: /algorithm\.iv must contain exactly 16 bytes/, + }))); +} + +// validateMaxBufferLength(). +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, ['deriveBits']); + await withPoisoned(poisonTypedArrayByteLength(0), common.mustCall(() => + assert.rejects( + subtle.deriveBits({ + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new Uint8Array(4096), + }, key, 8), + { + name: 'OperationError', + message: /algorithm\.info must be at most 1024 bytes/, + }))); +} + +// aesImportKey(). +await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(async () => { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'AES-GCM', true, ['encrypt']); + assert.strictEqual(key.algorithm.length, 256); +})); + +// validateCShakeFunctionName(). +if (supports('digest', 'cSHAKE128')) { + await withPoisoned(poisonTypedArrayByteLength(0), common.mustCall(() => + assert.rejects( + subtle.digest({ + name: 'cSHAKE128', + outputLength: 256, + functionName: new Uint8Array([0x41, 0x42, 0x43, 0x44]), + }, data), + { + name: 'NotSupportedError', + message: /Unsupported CShakeParams functionName/, + }))); + + // asyncDigest() picks the cSHAKE job over plain SHAKE on a non-empty + // customization. + const algorithm = { + name: 'cSHAKE128', + outputLength: 256, + customization: new Uint8Array([1, 2, 3]), + }; + const expected = new Uint8Array(await subtle.digest(algorithm, data)); + const plain = new Uint8Array( + await subtle.digest({ name: 'cSHAKE128', outputLength: 256 }, data)); + assert.notDeepStrictEqual(expected, plain); + await withPoisoned(poisonTypedArrayByteLength(0), + common.mustCall(async () => { + assert.deepStrictEqual( + new Uint8Array(await subtle.digest(algorithm, data)), + expected); + })); +} + +// AeadParams: AES-OCB caps the iv at 15 bytes. +if (supports('encrypt', 'AES-OCB')) { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(16), 'AES-OCB', false, ['encrypt']); + await withPoisoned(poisonTypedArrayByteLength(12), common.mustCall(() => + assert.rejects( + subtle.encrypt({ name: 'AES-OCB', iv: new Uint8Array(20) }, key, data), + { + name: 'OperationError', + message: /algorithm\.iv must be no more than 15 bytes/, + }))); +} + +// Argon2Params: the nonce has an 8 byte minimum. +if (supports('deriveBits', 'Argon2id')) { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'Argon2id', false, ['deriveBits']); + await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(() => + assert.rejects( + subtle.deriveBits({ + name: 'Argon2id', + nonce: new Uint8Array(4), + memory: 32, + passes: 1, + parallelism: 1, + }, key, 256), + { + name: 'OperationError', + message: /nonce must be at least 8 bytes/, + }))); +} + +// bigIntArrayToUnsignedInt(): TypedArray `length` is a prototype accessor. +await withPoisoned( + [{ target: TypedArrayPrototype, key: 'length', get: () => 0 }], + common.mustCall(async () => { + const { publicKey } = await subtle.generateKey({ + name: 'RSA-OAEP', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, true, ['encrypt', 'decrypt']); + assert.strictEqual(publicKey.algorithm.modulusLength, modulusLength); + assert.deepStrictEqual( + publicKey.algorithm.publicExponent, new Uint8Array([1, 0, 1])); + })); + +// ecdhDeriveBits() bounds the request by the native job's ArrayBuffer. +{ + const { privateKey, publicKey } = await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + await withPoisoned( + [{ target: ArrayBuffer.prototype, key: 'byteLength', get: () => 1e9 }], + common.mustCall(() => assert.rejects( + subtle.deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 8192), + { name: 'OperationError' }))); +} + +// simpleAlgorithmDictionaries relies on a miss returning undefined. +{ + const { privateKey, publicKey } = await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + await withPoisoned( + inherited('EcdhKeyDeriveParams', + { keys: ['public'], types: { public: 'BufferSource' } }), + common.mustCall(async () => { + const bits = await subtle.deriveBits( + { name: 'ECDH', public: publicKey }, privateKey, 128); + assert.strictEqual(bits.byteLength, 16); + })); + + await withPoisoned( + inherited('AesKeyGenParams', + { keys: ['name'], types: { name: 'AlgorithmIdentifier' } }), + common.mustCall(async () => { + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + assert.strictEqual(key.algorithm.length, 128); + })); +} + +// createDictionaryConverter() reads optional member descriptor keys. +{ + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + const encrypt = () => subtle.encrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, key, data); + + for (const poison of [ + inherited('required', true), + inherited('defaultValue', () => 9999), + inherited('validator', common.mustNotCall('Object.prototype.validator')), + ]) { + await withPoisoned(poison, common.mustCall(async () => { + assert.strictEqual((await encrypt()).byteLength, data.byteLength + 16); + })); + } +} + +// Conversion options are read by key by the Web IDL converters. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, ['deriveBits']); + const hkdf = (length) => subtle.deriveBits({ + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new Uint8Array(0), + }, key, length); + + // [EnforceRange] and [Clamp] are not set for a plain `unsigned long`, so + // 2 ** 32 wraps to 0 rather than throwing or clamping. + for (const attribute of ['enforceRange', 'clamp']) { + await withPoisoned( + inherited(attribute, true), + common.mustCall(async () => { + assert.strictEqual((await hkdf(2 ** 32)).byteLength, 0); + })); + } + + // [AllowResizable] is not set for BufferSource. + await withPoisoned(inherited('allowResizable', true), common.mustCall(() => + assert.rejects( + subtle.digest('SHA-256', new ArrayBuffer(8, { maxByteLength: 1024 })), + { name: 'TypeError' }))); + + // makeException() falls back to ERR_INVALID_ARG_TYPE. + await withPoisoned(inherited('code', 'ERR_POLLUTED'), common.mustCall(() => + assert.rejects(subtle.digest('SHA-256', 'not a BufferSource'), + { code: 'ERR_INVALID_ARG_TYPE' }))); +} + +// enforceRangeOptions(): [EnforceRange] uses IntegerPart, not round-half-even. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(4), 'PBKDF2', false, ['deriveBits']); + const pbkdf2 = (iterations) => subtle.deriveBits({ + name: 'PBKDF2', + hash: 'SHA-256', + salt: new Uint8Array(16), + iterations, + }, key, 8); + + const expected = new Uint8Array(await pbkdf2(1)); + await withPoisoned(inherited('clamp', true), common.mustCall(async () => { + assert.deepStrictEqual(new Uint8Array(await pbkdf2(1.5)), expected); + })); +} + +// keyDetail() is filled in by C++ with an ordinary [[Set]]. +{ + const { publicKey } = await subtle.generateKey({ + name: 'RSA-PSS', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, true, ['sign', 'verify']); + const spki = await subtle.exportKey('spki', publicKey); + + await withPoisoned([ + { + target: Object.prototype, key: 'modulusLength', + get: () => 8192, set() {}, + }, + { + target: Object.prototype, key: 'publicExponent', + get: () => new Uint8Array([9, 9, 9]), set() {}, + }, + ], common.mustCall(async () => { + const imported = await subtle.importKey( + 'spki', spki, { name: 'RSA-PSS', hash: 'SHA-256' }, true, ['verify']); + assert.strictEqual(imported.algorithm.modulusLength, modulusLength); + assert.deepStrictEqual( + imported.algorithm.publicExponent, new Uint8Array([1, 0, 1])); + })); +} + +{ + const { publicKey } = await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']); + const spki = await subtle.exportKey('spki', publicKey); + + await withPoisoned( + [{ + target: Object.prototype, key: 'namedCurve', + get: () => 'prime256v1', set() {}, + }], + common.mustCall(() => assert.rejects( + subtle.importKey( + 'spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']), + { name: 'DataError', message: /Named curve mismatch/ }))); +} + +// Key usages under a poisoned array iterator. Callers pass a Set so the +// spec-mandated sequence conversion still yields the requested usage; only +// WebCrypto's own re-iteration of that array sees the poison. +{ + // Every Set has to be built before the poison is installed, otherwise the + // Set constructor itself iterates its array argument and comes out empty. + const signOnly = new Set(['sign']); + const encryptOnly = new Set(['encrypt']); + const decryptOnly = new Set(['decrypt']); + const decapsulateKeyOnly = new Set(['decapsulateKey']); + + // Secret keys reject empty usages anyway, so match the message: the usage + // has to be rejected as unsupported, not as missing. + const cases = [ + { + name: 'AES-GCM', + message: /Unsupported key usage for AES-GCM key/, + importKey: () => subtle.importKey( + 'raw-secret', new Uint8Array(32), 'AES-GCM', false, signOnly), + }, + { + name: 'HKDF', + message: /Unsupported key usage for a HKDF key/, + importKey: () => subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, encryptOnly), + }, + ]; + + const addPublicKeyCase = async (name, algorithm, usages, disallowed) => { + if (!supports('importKey', name)) return; + const { publicKey } = await subtle.generateKey(algorithm, true, usages); + const spki = await subtle.exportKey('spki', publicKey); + cases.push({ + name, + importKey: () => subtle.importKey( + 'spki', spki, algorithm, true, disallowed), + }); + }; + + await addPublicKeyCase('ECDSA', { name: 'ECDSA', namedCurve: 'P-256' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('Ed25519', { name: 'Ed25519' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('RSA-OAEP', { + name: 'RSA-OAEP', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, ['encrypt', 'decrypt'], decryptOnly); + await addPublicKeyCase('ML-DSA-44', { name: 'ML-DSA-44' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('ML-KEM-512', { name: 'ML-KEM-512' }, + ['encapsulateKey', 'decapsulateKey'], + decapsulateKeyOnly); + + for (const { name, message, importKey } of cases) { + const outcome = await settleUnderPoison(poisonArrayIterator, importKey); + assert.strictEqual(outcome.value, undefined, name); + assert.strictEqual(outcome.error?.name, 'SyntaxError', name); + if (message !== undefined) assert.match(outcome.error.message, message); + } +} + +// The registry, the Web IDL converters and the hash name aliases are built at +// module load, so poisoning those needs a fresh process. The child bodies are +// written as real functions and stringified into -e so that they stay linted. +async function runInFreshProcess(fn, args, expected) { + const { code, stdout, stderr } = await common.spawnPromisified( + process.execPath, ['-e', `(${fn})(${args})`]); + assert.strictEqual(code, 0, stderr); + assert.strictEqual(stdout.trim(), expected, stderr); +} + +// Only the load happens under the poison: a sequence argument would +// legitimately come out empty while the caller's iterator is broken. +async function pollutedArrayIteratorChild(kmac) { + const real = Array.prototype[Symbol.iterator]; + Array.prototype[Symbol.iterator] = () => ({ next: () => ({ done: true }) }); + const { subtle } = globalThis.crypto; + const out = []; + try { + out.push((await subtle.digest('SHA-256', new Uint8Array(4))).byteLength); + } finally { + Array.prototype[Symbol.iterator] = real; + } + const hmac = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + out.push((await subtle.sign('HMAC', hmac, new Uint8Array(4))).byteLength); + const aes = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + out.push((await subtle.encrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, aes, new Uint8Array(4), + )).byteLength); + if (kmac) { + const key = await subtle.generateKey( + { name: 'KMAC128', length: 128 }, false, ['sign']); + out.push((await subtle.sign( + { name: 'KMAC128', outputLength: 256 }, key, new Uint8Array(4), + )).byteLength); + } + console.log(out.join(',')); +} + +// kHashNames indexes its aliases at load time. +async function pollutedHashNameChild() { + Object.prototype['SHA-256'] = { 1: 'md5', 2: 'POLLUTED' }; + const { subtle } = globalThis.crypto; + const key = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, true, ['sign']); + const signature = await subtle.sign('HMAC', key, new Uint8Array(4)); + const { alg } = await subtle.exportKey('jwk', key); + console.log(`${signature.byteLength},${alg}`); +} + +{ + const kmac = supports('generateKey', 'KMAC128'); + await runInFreshProcess(pollutedArrayIteratorChild, kmac, + kmac ? '32,32,20,32' : '32,32,20'); + await runInFreshProcess(pollutedHashNameChild, '', '32,HS256'); +} From e0ce1358a90d7706ae72ad510c61a7a5cd6a2af8 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 7 Aug 2026 21:11:40 +0200 Subject: [PATCH 3/3] fixup! crypto: read WebCrypto inputs through primordials --- .../test-webcrypto-prototype-pollution.mjs | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/test/parallel/test-webcrypto-prototype-pollution.mjs b/test/parallel/test-webcrypto-prototype-pollution.mjs index 1f82a84f1d41..8bb93cff0ae5 100644 --- a/test/parallel/test-webcrypto-prototype-pollution.mjs +++ b/test/parallel/test-webcrypto-prototype-pollution.mjs @@ -14,6 +14,7 @@ if (!common.hasCrypto) common.skip('missing crypto'); const require = createRequire(import.meta.url); const { kSupportedAlgorithms } = require('internal/crypto/util'); const { getFips } = require('node:crypto'); +const { hasOpenSSL } = require('../common/crypto'); const { subtle } = globalThis.crypto; const TypedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); @@ -135,21 +136,23 @@ if (supports('digest', 'cSHAKE128')) { // asyncDigest() picks the cSHAKE job over plain SHAKE on a non-empty // customization. - const algorithm = { - name: 'cSHAKE128', - outputLength: 256, - customization: new Uint8Array([1, 2, 3]), - }; - const expected = new Uint8Array(await subtle.digest(algorithm, data)); - const plain = new Uint8Array( - await subtle.digest({ name: 'cSHAKE128', outputLength: 256 }, data)); - assert.notDeepStrictEqual(expected, plain); - await withPoisoned(poisonTypedArrayByteLength(0), - common.mustCall(async () => { - assert.deepStrictEqual( - new Uint8Array(await subtle.digest(algorithm, data)), - expected); - })); + if (hasOpenSSL(3)) { + const algorithm = { + name: 'cSHAKE128', + outputLength: 256, + customization: new Uint8Array([1, 2, 3]), + }; + const expected = new Uint8Array(await subtle.digest(algorithm, data)); + const plain = new Uint8Array( + await subtle.digest({ name: 'cSHAKE128', outputLength: 256 }, data)); + assert.notDeepStrictEqual(expected, plain); + await withPoisoned(poisonTypedArrayByteLength(0), + common.mustCall(async () => { + assert.deepStrictEqual( + new Uint8Array(await subtle.digest(algorithm, data)), + expected); + })); + } } // AeadParams: AES-OCB caps the iv at 15 bytes.