I know that WeakMap
and WeakSet
are not iterable for security reasons, that is, “to prevent attackers from seeing the garbage collector’s internal behavior,” but then, this means you cannot clone a WeakMap
or WeakSet
the way you clone a Map
or Set
, cloned_map = new Map(existing_map), cloned_set = new Set(existing_set)
.
How do I clone a WeakMap
or WeakSet
in Javascript? By cloning, I mean creating another WeakMap
or WeakSet
with the same weak references.
WeakMap
/WeakSet
not "cloneable"?WeakMap
s and WeakSet
s are not "cloneable" for the same reason as why you can't iterate them.
Namely to avoid exposing the latency between the time the key becomes inaccessible and when it is removed from the WeakMap
/ WeakSet
. (The reasoning for this is already covered your linked question )
ECMAScript 2023 Language Specification, 24.3 WeakMap Objects
An implementation may impose an arbitrarily determined latency between the time a key/value pair of a WeakMap becomes inaccessible and the time when the key/value pair is removed from the WeakMap. If this latency was observable to ECMAScript program, it would be a source of indeterminacy that could impact program execution. For that reason, an ECMAScript implementation must not provide any means to observe a key of a WeakMap that does not require the observer to present the observed key.
Think about how new WeakMap(existingWeakMap)
would need to be implemented.
To create a new WeakMap
from an existing one would require iterating over its elements and copying them over to the new one.
And depending on how many elements there are in the WeakMap
, this operation would take a varying amount of time (it would take a lot longer to copy a WeakMap
with 100'000 entries than to copy one with none).
And that gives you an attack vector: You can guesstimate the number of key-value pairs within the WeakMap by measuring how long it takes to clone it.
Here's a runnable snippet that uses this technique to guess to number of entries within a Map
(could be easily used against WeakMap
, if it were clonable):
Note that due to Spectre mitigations performance.now()
in browsers is typically rounded, so a larger margin of error in the guesses should be expected.
function measureCloneTime(map) {
const begin = performance.now();
const cloneMap = new Map(map);
const end = performance.now();
return end-begin;
}
function measureAvgCloneTime(map, numSamples = 50) {
let timeSum = 0;
for(let i = 0; i < numSamples; i++) {
timeSum += measureCloneTime(map);
}
return timeSum / numSamples;
}
function makeMapOfSize(n) {
return new Map(Array(n).fill(null).map(() => [{}, {}]));
}
// prime JIT
for(let i = 0; i < 10000; i++) {
measureAvgCloneTime(makeMapOfSize(50));
}
const avgCloneTimes = [
{size: 2**6, time: measureAvgCloneTime(makeMapOfSize(2**6))},
{size: 2**7, time: measureAvgCloneTime(makeMapOfSize(2**7))},
{size: 2**8, time: measureAvgCloneTime(makeMapOfSize(2**8))},
{size: 2**9, time: measureAvgCloneTime(makeMapOfSize(2**9))},
{size: 2**10, time: measureAvgCloneTime(makeMapOfSize(2**10))},
{size: 2**11, time: measureAvgCloneTime(makeMapOfSize(2**11))},
{size: 2**12, time: measureAvgCloneTime(makeMapOfSize(2**12))},
{size: 2**13, time: measureAvgCloneTime(makeMapOfSize(2**13))},
{size: 2**14, time: measureAvgCloneTime(makeMapOfSize(2**14))},
];
function guessMapSizeBasedOnCloneSpeed(map) {
const cloneTime = measureAvgCloneTime(map);
let closestMatch = avgCloneTimes.find(e => e.time > cloneTime);
if(!closestMatch) {
closestMatch = avgCloneTimes[avgCloneTimes.length - 1];
}
const sizeGuess = Math.round(
(cloneTime / closestMatch.time) * closestMatch.size
);
console.log("Real Size: " + map.size + " - Guessed Size: " + sizeGuess);
}
guessMapSizeBasedOnCloneSpeed(makeMapOfSize(1000));
guessMapSizeBasedOnCloneSpeed(makeMapOfSize(4000));
guessMapSizeBasedOnCloneSpeed(makeMapOfSize(6000));
guessMapSizeBasedOnCloneSpeed(makeMapOfSize(10000));
On my machine (Ubuntu 20, Chrome 107) i got the following output (YMMV):
Real Size: 1000 - Guessed Size: 1037
Real Size: 4000 - Guessed Size: 3462
Real Size: 6000 - Guessed Size: 6329
Real Size: 10000 - Guessed Size: 9889
As you can see it is incredibly easy to guess the size of a Map
just by cloning it. (by refining the algorithm / taking more samples / using a more accurate time source it could be made even more accurate)
And that's why you can't clone WeakMap
/ WeakSet
.
If you need a clonable / iterable WeakMap
/ WeakSet
you could build your own by using WeakRef
and FinalizationRegistry
.
Here's an example how you could build an iterable WeakMap
:
class IterableWeakMap {
#weakMap = new WeakMap();
#refSet = new Set();
#registry = new FinalizationRegistry(this.#cleanup.bind(this));
#cleanup(value) {
this.#refSet.delete(value);
}
constructor(iterable) {
if(iterable) {
for(const [key, value] of iterable) {
this.set(key, value);
}
}
}
get(key) {
return this.#weakMap.get(key)?.value;
}
has(key) {
return this.#weakMap.has(key);
}
set(key, value) {
let entry = this.#weakMap.get(key);
if(!entry) {
const ref = new WeakRef(key);
this.#registry.register(key, ref, key);
entry = {ref, value: null};
this.#weakMap.set(key, entry);
this.#refSet.add(ref);
}
entry.value = value;
return this;
}
delete(key) {
const entry = this.#weakMap.get(key);
if(!entry) {
return false;
}
this.#weakMap.delete(key);
this.#refSet.delete(entry.ref);
this.#registry.unregister(key);
return true;
}
clear() {
for(const ref of this.#refSet) {
const el = ref.deref();
if(el !== undefined) {
this.#registry.unregister(el);
}
}
this.#weakMap = new WeakMap();
this.#refSet.clear();
}
*entries() {
for(const ref of this.#refSet) {
const el = ref.deref();
if(el !== undefined) {
yield [el, this.#weakMap.get(el).value];
}
}
}
*keys() {
for(const ref of this.#refSet) {
const el = ref.deref();
if(el !== undefined) {
yield el;
}
}
}
*values() {
for(const ref of this.#refSet) {
const el = ref.deref();
if(el !== undefined) {
yield this.#weakMap.get(el).value;
}
}
}
forEach(callbackFn, thisArg) {
for(const [key, value] of this.entries()) {
callbackFn.call(thisArg, value, key, this);
}
}
[Symbol.iterator]() {
return this.entries();
}
get size() {
let size = 0;
for(const key of this.keys()) {
size++;
}
return size;
}
static get [Symbol.species]() {
return IterableWeakMap;
}
}
// Usage Example:
let foo = {foo: 42};
let bar = {bar: 42};
const map = new IterableWeakMap([
[foo, "foo"],
[bar, "bar"]
]);
const clonedMap = new IterableWeakMap(map);
console.log([...clonedMap.entries()]);