Skip to content

Commit e9ecc87

Browse files
committed
crypto: harden WebCrypto against prototype pollution
Avoid re-wrapping native WebCrypto promises with PromiseResolve(), since resolving a promise can read its user-mutated constructor. Add a helper for chaining internal WebCrypto job promises without consulting Promise species state, and use it for intermediate job results. Also align JWK wrapping and unwrapping with the spec's fresh-global JSON handling by detaching internal JWK values from user prototypes. Use the internal UTF-8 encoder/decoder bindings instead of shared TextEncoder/TextDecoder prototype methods. Expand the WebCrypto prototype pollution regression test to cover SubtleCrypto methods, export formats, zero-length KDF results, JWK toJSON/kty pollution, and encoder/decoder prototype poisoning. Signed-off-by: Filip Skokan <panva.ip@gmail.com>
1 parent 9bdd823 commit e9ecc87

12 files changed

Lines changed: 729 additions & 152 deletions

lib/internal/crypto/diffiehellman.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const {
55
FunctionPrototypeCall,
66
MathCeil,
77
ObjectDefineProperty,
8-
PromisePrototypeThen,
98
TypedArrayPrototypeGetBuffer,
109
Uint8Array,
1110
} = primordials;
@@ -59,6 +58,7 @@ const {
5958
const {
6059
getArrayBufferOrView,
6160
jobPromise,
61+
jobPromiseThen,
6262
toBuf,
6363
kHandle,
6464
} = require('internal/crypto/util');
@@ -368,7 +368,7 @@ function ecdhDeriveBits(algorithm, baseKey, length) {
368368
if (length === null)
369369
return bits;
370370

371-
return PromisePrototypeThen(bits, (bits) => {
371+
return jobPromiseThen(bits, (bits) => {
372372
// If the length is not a multiple of 8 the nearest ceiled
373373
// multiple of 8 is sliced.
374374
const sliceLength = MathCeil(length / 8);

lib/internal/crypto/util.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
ObjectEntries,
1515
ObjectKeys,
1616
ObjectPrototypeHasOwnProperty,
17+
PromisePrototypeThen,
1718
PromiseReject,
1819
SafeMap,
1920
SafeSet,
@@ -678,6 +679,18 @@ function jobPromise(getJob) {
678679
}
679680
}
680681

682+
// Promise.prototype.then gets promise.constructor to determine the result
683+
// promise's species. These promises are internal WebCrypto intermediates, so
684+
// make that lookup stay on the promise itself instead of user-mutated state.
685+
function jobPromiseThen(promise, onFulfilled, onRejected) {
686+
ObjectDefineProperty(promise, 'constructor', {
687+
__proto__: null,
688+
configurable: true,
689+
value: undefined,
690+
});
691+
return PromisePrototypeThen(promise, onFulfilled, onRejected);
692+
}
693+
681694
// In WebCrypto, the publicExponent option in RSA is represented as a
682695
// WebIDL "BigInteger"... that is, a Uint8Array that allows an arbitrary
683696
// number of leading zero bits. Our conventional APIs for reading
@@ -900,6 +913,7 @@ module.exports = {
900913
validateByteSource,
901914
validateKeyOps,
902915
jobPromise,
916+
jobPromiseThen,
903917
validateMaxBufferLength,
904918
bigIntArrayToUnsignedBigInt,
905919
bigIntArrayToUnsignedInt,

lib/internal/crypto/webcrypto.js

Lines changed: 125 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
'use strict';
22

33
const {
4+
ArrayIsArray,
45
ArrayPrototypeSlice,
56
FunctionPrototypeCall,
67
JSONParse,
78
JSONStringify,
89
ObjectDefineProperties,
9-
PromisePrototypeThen,
10+
ObjectDefineProperty,
11+
ObjectKeys,
12+
ObjectSetPrototypeOf,
1013
PromiseReject,
1114
PromiseResolve,
1215
ReflectApply,
1316
ReflectConstruct,
17+
SafeArrayIterator,
1418
StringPrototypeRepeat,
1519
StringPrototypeSlice,
1620
StringPrototypeStartsWith,
21+
SymbolIterator,
1722
SymbolToStringTag,
1823
TypedArrayPrototypeGetBuffer,
1924
} = primordials;
@@ -26,7 +31,10 @@ const {
2631
kWebCryptoCipherDecrypt,
2732
} = internalBinding('crypto');
2833

29-
const { TextDecoder, TextEncoder } = require('internal/encoding');
34+
const {
35+
decodeUTF8,
36+
encodeUtf8String,
37+
} = internalBinding('encoding_binding');
3038

3139
const {
3240
codes: {
@@ -55,6 +63,7 @@ const {
5563

5664
const {
5765
getBlockSize,
66+
jobPromiseThen,
5867
normalizeAlgorithm,
5968
normalizeHashName,
6069
validateMaxBufferLength,
@@ -71,13 +80,20 @@ const {
7180
randomUUID: _randomUUID,
7281
} = require('internal/crypto/random');
7382

83+
const {
84+
isPromise,
85+
} = require('internal/util/types');
86+
7487
let webidl;
7588

7689
// WebCrypto methods return promises, including for synchronous validation
7790
// failures. Keep that conversion in one place so method bodies stay readable.
7891
function callSubtleCryptoMethod(fn, receiver, args) {
7992
try {
80-
return PromiseResolve(ReflectApply(fn, receiver, args));
93+
const result = ReflectApply(fn, receiver, args);
94+
// PromiseResolve(promise) reads promise.constructor. Avoid re-wrapping
95+
// native promises so Promise.prototype.constructor pollution is ignored.
96+
return isPromise(result) ? result : PromiseResolve(result);
8197
} catch (err) {
8298
return PromiseReject(err);
8399
}
@@ -384,7 +400,7 @@ function deriveKeyImpl(
384400
throw lazyDOMException('Unrecognized algorithm name', 'NotSupportedError');
385401
}
386402

387-
return PromisePrototypeThen(bits, (bits) => FunctionPrototypeCall(
403+
return jobPromiseThen(bits, (bits) => FunctionPrototypeCall(
388404
importKeySync,
389405
this,
390406
'raw-secret', bits, derivedKeyAlgorithm, extractable, keyUsages,
@@ -569,32 +585,37 @@ function exportKeyRawSecret(key, format) {
569585
}
570586
}
571587

588+
// Creates a descriptor for an own enumerable JWK parameter.
589+
function jwkPropertyDescriptor(value) {
590+
return {
591+
__proto__: null,
592+
configurable: true,
593+
enumerable: true,
594+
value,
595+
writable: true,
596+
};
597+
}
598+
572599
function exportKeyJWK(key) {
573600
const algorithm = getCryptoKeyAlgorithm(key);
574-
const parameters = {
575-
key_ops: ArrayPrototypeSlice(getCryptoKeyUsages(key), 0),
576-
ext: getCryptoKeyExtractable(key),
577-
};
601+
let alg;
578602
switch (algorithm.name) {
579603
case 'RSASSA-PKCS1-v1_5': {
580-
const alg = normalizeHashName(
604+
alg = normalizeHashName(
581605
algorithm.hash.name,
582606
normalizeHashName.kContextJwkRsa);
583-
if (alg) parameters.alg = alg;
584607
break;
585608
}
586609
case 'RSA-PSS': {
587-
const alg = normalizeHashName(
610+
alg = normalizeHashName(
588611
algorithm.hash.name,
589612
normalizeHashName.kContextJwkRsaPss);
590-
if (alg) parameters.alg = alg;
591613
break;
592614
}
593615
case 'RSA-OAEP': {
594-
const alg = normalizeHashName(
616+
alg = normalizeHashName(
595617
algorithm.hash.name,
596618
normalizeHashName.kContextJwkRsaOaep);
597-
if (alg) parameters.alg = alg;
598619
break;
599620
}
600621
case 'ECDSA':
@@ -620,7 +641,7 @@ function exportKeyJWK(key) {
620641
case 'Ed25519':
621642
// Fall through
622643
case 'Ed448':
623-
parameters.alg = algorithm.name;
644+
alg = algorithm.name;
624645
break;
625646
case 'AES-CTR':
626647
// Fall through
@@ -631,30 +652,40 @@ function exportKeyJWK(key) {
631652
case 'AES-OCB':
632653
// Fall through
633654
case 'AES-KW':
634-
parameters.alg = require('internal/crypto/aes')
655+
alg = require('internal/crypto/aes')
635656
.getAlgorithmName(algorithm.name, algorithm.length);
636657
break;
637658
case 'ChaCha20-Poly1305':
638-
parameters.alg = 'C20P';
659+
alg = 'C20P';
639660
break;
640661
case 'HMAC': {
641-
const alg = normalizeHashName(
662+
alg = normalizeHashName(
642663
algorithm.hash.name,
643664
normalizeHashName.kContextJwkHmac);
644-
if (alg) parameters.alg = alg;
645665
break;
646666
}
647667
case 'KMAC128':
648-
parameters.alg = 'K128';
668+
alg = 'K128';
649669
break;
650670
case 'KMAC256': {
651-
parameters.alg = 'K256';
671+
alg = 'K256';
652672
break;
653673
}
654674
default:
655675
return undefined;
656676
}
657677

678+
const parameters = {};
679+
const descriptors = {
680+
__proto__: null,
681+
key_ops: jwkPropertyDescriptor(ArrayPrototypeSlice(getCryptoKeyUsages(key), 0)),
682+
ext: jwkPropertyDescriptor(getCryptoKeyExtractable(key)),
683+
};
684+
if (alg !== undefined) {
685+
descriptors.alg = jwkPropertyDescriptor(alg);
686+
}
687+
ObjectDefineProperties(parameters, descriptors);
688+
658689
return getCryptoKeyHandle(key).exportJwk(parameters, true);
659690
}
660691

@@ -748,6 +779,64 @@ function exportKeyImpl(format, key) {
748779
return exportKeySync(format, key);
749780
}
750781

782+
// Parsed JWK arrays are detached from Array.prototype but still need to pass
783+
// WebIDL sequence conversion, which reads @@iterator from the value.
784+
function safeArrayIterator() {
785+
return new SafeArrayIterator(this);
786+
}
787+
788+
// The WebCrypto spec parses and stringifies JWKs in a fresh global object.
789+
// Detach internal JSON values from the current global's mutable prototypes to
790+
// approximate those fresh-realm semantics without creating a new realm.
791+
function detachFromUserPrototypes(value) {
792+
if (value === null || typeof value !== 'object')
793+
return;
794+
795+
ObjectSetPrototypeOf(value, null);
796+
797+
if (ArrayIsArray(value)) {
798+
ObjectDefineProperty(value, SymbolIterator, {
799+
__proto__: null,
800+
configurable: true,
801+
value: safeArrayIterator,
802+
});
803+
for (let n = 0; n < value.length; n++)
804+
detachFromUserPrototypes(value[n]);
805+
return;
806+
}
807+
808+
const keys = ObjectKeys(value);
809+
for (let n = 0; n < keys.length; n++)
810+
detachFromUserPrototypes(value[keys[n]]);
811+
}
812+
813+
// Parse wrapped JWK bytes according to WebCrypto's "parse a JWK" procedure.
814+
function parseJwk(keyData) {
815+
let key;
816+
try {
817+
// WebCrypto parses JWKs in a fresh global. Detach parsed JSON values
818+
// from user-mutated prototypes before WebIDL dictionary conversion.
819+
// Wrapped JWKs may be produced outside WebCrypto, so parse using the
820+
// spec-required UTF-8.
821+
const json = decodeUTF8(keyData, false, true);
822+
const result = JSONParse(json);
823+
detachFromUserPrototypes(result);
824+
key = webidl.converters.JsonWebKey(result);
825+
} catch (err) {
826+
throw lazyDOMException(
827+
'Invalid wrapped JWK key',
828+
{ name: 'DataError', cause: err });
829+
}
830+
831+
if (key.kty === undefined) {
832+
throw lazyDOMException(
833+
'Invalid wrapped JWK key',
834+
'DataError');
835+
}
836+
837+
return key;
838+
}
839+
751840
function aliasKeyFormat(format) {
752841
switch (format) {
753842
case 'raw-public':
@@ -967,15 +1056,21 @@ function wrapKeyImpl(format, key, wrappingKey, algorithm) {
9671056
let keyData = exportKeySync(format, key);
9681057

9691058
if (format === 'jwk') {
970-
const ec = new TextEncoder();
971-
const raw = JSONStringify(keyData);
1059+
// The WebCrypto spec stringifies JWKs in a new global object. Rather
1060+
// than create a new realm here, detach this internally generated JWK from
1061+
// user-mutated prototypes so JSON.stringify cannot read inherited toJSON
1062+
// hooks from the current global.
1063+
detachFromUserPrototypes(keyData);
1064+
const json = JSONStringify(keyData);
9721065
// As per the NOTE in step 13 https://w3c.github.io/webcrypto/#SubtleCrypto-method-wrapKey
9731066
// we're padding AES-KW wrapped JWK to make sure it is always a multiple of 8 bytes
9741067
// in length
975-
if (algorithm.name === 'AES-KW' && raw.length % 8 !== 0) {
976-
keyData = ec.encode(raw + StringPrototypeRepeat(' ', 8 - (raw.length % 8)));
1068+
// The spec then UTF-8 encodes json.
1069+
if (algorithm.name === 'AES-KW' && json.length % 8 !== 0) {
1070+
keyData = encodeUtf8String(
1071+
json + StringPrototypeRepeat(' ', 8 - (json.length % 8)));
9771072
} else {
978-
keyData = ec.encode(raw);
1073+
keyData = encodeUtf8String(json);
9791074
}
9801075
}
9811076

@@ -1067,17 +1162,9 @@ function unwrapKeyImpl(
10671162
wrappedKey,
10681163
'unwrapKey');
10691164

1070-
return PromisePrototypeThen(keyData, (keyData) => {
1165+
return jobPromiseThen(keyData, (keyData) => {
10711166
if (format === 'jwk') {
1072-
// The fatal: true option is only supported in builds that have ICU.
1073-
const options = process.versions.icu !== undefined ?
1074-
{ fatal: true } : undefined;
1075-
const dec = new TextDecoder('utf-8', options);
1076-
try {
1077-
keyData = JSONParse(dec.decode(keyData));
1078-
} catch {
1079-
throw lazyDOMException('Invalid wrapped JWK key', 'DataError');
1080-
}
1167+
keyData = parseJwk(keyData);
10811168
}
10821169

10831170
return FunctionPrototypeCall(
@@ -1453,7 +1540,7 @@ function encapsulateKeyImpl(
14531540
throw lazyDOMException('Unrecognized algorithm name', 'NotSupportedError');
14541541
}
14551542

1456-
return PromisePrototypeThen(encapsulateBits, (encapsulateBits) => {
1543+
return jobPromiseThen(encapsulateBits, (encapsulateBits) => {
14571544
const sharedKey = FunctionPrototypeCall(
14581545
importKeySync,
14591546
this,
@@ -1593,7 +1680,7 @@ function decapsulateKeyImpl(
15931680
throw lazyDOMException('Unrecognized algorithm name', 'NotSupportedError');
15941681
}
15951682

1596-
return PromisePrototypeThen(decapsulatedBits, (decapsulatedBits) => FunctionPrototypeCall(
1683+
return jobPromiseThen(decapsulatedBits, (decapsulatedBits) => FunctionPrototypeCall(
15971684
importKeySync,
15981685
this,
15991686
'raw-secret', decapsulatedBits, normalizedSharedKeyAlgorithm, extractable,

0 commit comments

Comments
 (0)