Skip to content

Commit b93b394

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 b93b394

4 files changed

Lines changed: 488 additions & 68 deletions

File tree

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: 93 additions & 21 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,
@@ -748,6 +764,64 @@ function exportKeyImpl(format, key) {
748764
return exportKeySync(format, key);
749765
}
750766

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

9691043
if (format === 'jwk') {
970-
const ec = new TextEncoder();
971-
const raw = JSONStringify(keyData);
1044+
// The WebCrypto spec stringifies JWKs in a new global object. Rather
1045+
// than create a new realm here, detach this internally generated JWK from
1046+
// user-mutated prototypes so JSON.stringify cannot read inherited toJSON
1047+
// hooks from the current global.
1048+
detachFromUserPrototypes(keyData);
1049+
const json = JSONStringify(keyData);
9721050
// As per the NOTE in step 13 https://w3c.github.io/webcrypto/#SubtleCrypto-method-wrapKey
9731051
// we're padding AES-KW wrapped JWK to make sure it is always a multiple of 8 bytes
9741052
// in length
975-
if (algorithm.name === 'AES-KW' && raw.length % 8 !== 0) {
976-
keyData = ec.encode(raw + StringPrototypeRepeat(' ', 8 - (raw.length % 8)));
1053+
// The spec then UTF-8 encodes json.
1054+
if (algorithm.name === 'AES-KW' && json.length % 8 !== 0) {
1055+
keyData = encodeUtf8String(
1056+
json + StringPrototypeRepeat(' ', 8 - (json.length % 8)));
9771057
} else {
978-
keyData = ec.encode(raw);
1058+
keyData = encodeUtf8String(json);
9791059
}
9801060
}
9811061

@@ -1067,17 +1147,9 @@ function unwrapKeyImpl(
10671147
wrappedKey,
10681148
'unwrapKey');
10691149

1070-
return PromisePrototypeThen(keyData, (keyData) => {
1150+
return jobPromiseThen(keyData, (keyData) => {
10711151
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-
}
1152+
keyData = parseJwk(keyData);
10811153
}
10821154

10831155
return FunctionPrototypeCall(
@@ -1453,7 +1525,7 @@ function encapsulateKeyImpl(
14531525
throw lazyDOMException('Unrecognized algorithm name', 'NotSupportedError');
14541526
}
14551527

1456-
return PromisePrototypeThen(encapsulateBits, (encapsulateBits) => {
1528+
return jobPromiseThen(encapsulateBits, (encapsulateBits) => {
14571529
const sharedKey = FunctionPrototypeCall(
14581530
importKeySync,
14591531
this,
@@ -1593,7 +1665,7 @@ function decapsulateKeyImpl(
15931665
throw lazyDOMException('Unrecognized algorithm name', 'NotSupportedError');
15941666
}
15951667

1596-
return PromisePrototypeThen(decapsulatedBits, (decapsulatedBits) => FunctionPrototypeCall(
1668+
return jobPromiseThen(decapsulatedBits, (decapsulatedBits) => FunctionPrototypeCall(
15971669
importKeySync,
15981670
this,
15991671
'raw-secret', decapsulatedBits, normalizedSharedKeyAlgorithm, extractable,

0 commit comments

Comments
 (0)