Proxy Traps and the Invariants the Runtime Enforces
One probe on fixed traps: which traps fire, which answers the runtime rejects with a TypeError, and what a revoked or non-callable proxy does.
BotBrowser Team
Want the structured docs for Fingerprint?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping a bounded record of which traps a page installed and which answers the engine refused, but it cannot make a proxy break an invariant the engine enforces, cannot keep a revoked proxy usable, and cannot turn a trap on a non-callable target into a call. This review runs one probe over nine fixed traps and records the trap that fires for a plain read, the six invariants that reject a plausible-looking trap answer with a TypeError, the two traps that actually change a value when the target allows it, what a revoked proxy does to every access, and why an object target cannot answer a call.
TL;DR
- A plain property read fires only the
gettrap, so the probe printsorder=get:awith no descriptor lookup. - Reporting fewer keys than a non-extensible target owns throws a
TypeError, so a trap cannot hide a property the target kept. - Reporting a non-configurable property the target does not have throws a
TypeError, so a trap cannot invent a locked property. - Returning
truefromsetfor a frozen property throws aTypeErrorand leaves the target unchanged at1. - A
settrap that writes through to the target does change it, so the probe printstarget=42after doubling 21. - Changing the prototype of an extensible target is allowed, while the same trap on a non-extensible target throws a
TypeError. - A revoked proxy throws a
TypeErroron a read, aincheck andObject.keysalike. - An
applytrap doubles the work into50, while the same trap on an object target throws aTypeErrorbecause the target is not callable. - A
deletePropertytrap cannot report a non-configurable property as deleted, and the property stays.
Contents
- Which trap a plain read fires
- Why the engine rejects a plausible trap answer
- Where a trap really changes a value
- What a revoked proxy and a non-callable target do
- Record a proxy reading
Which trap a plain read fires
A proxy forwards operations to traps, and each operation reaches a specific set of them. The probe reads one property and records every trap that ran:
node=v26.9.0
read-order -> value=1 order=get:a
ownKeys-invariant -> TypeError
gopd-invariant -> TypeError
set-invariant -> TypeError targetValue=1
set-doubled -> target=42
prototype-invariant -> extensible=allowed non-extensible=TypeError
revoked -> TypeError,TypeError,TypeError
apply-trap -> result=50 object-target=TypeError
delete-invariant -> TypeError stillThere=true
Only get fired. That matters because a page that instruments a proxy by counting getOwnPropertyDescriptor calls to detect reads counts nothing, and a page that expects has to run on every read gets a surprise. Each operation is a one-to-one mapping to its trap, and a read is the narrowest one.
The same mapping decides what a detection check observes. A fingerprinting probe that compares a plain object to a proxy often does it by triggering operations that force a trap, and a read alone forces one; a descriptor lookup forces a different one, and the probe's line shows the two are not both triggered by the same statement. That is why a proxy that answers a read can still look like a plain object to a check that only reads.
The property order a proxy filters is the one object key order describes for a plain object. The full mapping is finite and worth memorising, because each entry decides which trap a given statement reaches. get serves a property read, set serves an assignment, has serves the in operator, deleteProperty serves delete, ownKeys serves Object.keys and Reflect.ownKeys, getOwnPropertyDescriptor serves Object.getOwnPropertyDescriptor and the internal lookup that Object.keys performs before ownKeys, defineProperty serves Object.defineProperty, getPrototypeOf and setPrototypeOf serve the two prototype operations, isExtensible and preventExtensions serve the two extensibility operations, and apply and construct serve a call and a new on a callable target. A page that expects one trap from one statement is right exactly when that statement is on the list.
Two of those entries pair, which produces an order rather than a single call. Object.keys first invokes ownKeys to get the candidate list and then, for each key it means to keep, invokes getOwnPropertyDescriptor to check whether the property is enumerable. A proxy that answers ownKeys but not getOwnPropertyDescriptor therefore still works, because the default descriptor trap forwards to the target. A proxy that answers getOwnPropertyDescriptor with a non-enumerable descriptor for a key it listed in ownKeys silently drops that key from the result, and a page that counts ownKeys calls to infer how many properties a proxy exposes over-counts by exactly the keys it filtered out.
The same pairs show up in a benchmark that compares proxy cost to plain-object cost. Every instrumented operation costs at least the trap call plus whatever the trap does, and the paired operations cost two trap calls in one statement, which is why a proxy over a hot loop of Object.keys is measurably slower than one over a hot loop of reads. The probe cannot time that on nine operations, and the ordering is identical either way, so the cost difference is a property of the mapping rather than of any particular trap body.
Why the engine rejects a plausible trap answer
A trap may return a value that contradicts the target, and the engine enforces a fixed set of invariants that reject those answers. The probe drives six of them and every one throws a TypeError:
ownKeys-invariant -> TypeError
gopd-invariant -> TypeError
The first case is a non-extensible target with two keys where the ownKeys trap reports one, and the engine refuses to let a trap hide a key the target still owns. The second case has a trap that reports a non-configurable property the target does not have, and the engine refuses to let a trap invent a locked property. Both answers look reasonable in isolation, and both are the exact mismatches the invariant exists to catch.
The third case is the one that trips a caching proxy. A frozen target plus a set trap that returns true throws, and the target is unchanged:
set-invariant -> TypeError targetValue=1
The trap claimed success, and the assertion the invariant checks is whether the target's own state agrees. A page that wraps a frozen configuration object in a logging proxy and always returns true from set gets a throw on the first attempted write rather than a silently ignored one, which is the correct outcome and the one the page's author usually did not expect.
The fourth pair is about identity and deletion:
prototype-invariant -> extensible=allowed non-extensible=TypeError
delete-invariant -> TypeError stillThere=true
Changing the reported prototype of an extensible target is allowed, because the target can still change to match; the same trap on a non-extensible target throws, because the target can no longer move to the claimed prototype. Deleting a non-configurable property through a trap that returns true throws, because the target still has the property. In both cases the invariant is the same idea in a different key: a trap may not report an object state that the target's own attributes forbid.
The general rule behind all six is worth stating once: a proxy is a filter, not a replacement. The engine compares every trap answer against the target's own property attributes, and any answer that would require the target to violate those attributes is rejected. That is why a proxy cannot be used to pretend a frozen object is mutable, or that a sealed object has fewer keys, or that a property is both present and non-configurable when the target has no such property.
There is a fifth invariant the probe exercises through set and one more that is easiest to see as a pair with it. The set invariant has two halves: a trap may not report success for a property the target marks non-writable, and a trap may not report success for a property the target does not have while the target is non-extensible. The probe's frozen case is the first half. The second half appears when a proxy over a sealed object's set trap returns true for a brand-new key: the target cannot grow, so the engine throws even though the trap never touched it. A logging proxy that forwards every write with Reflect.set never sees either, because Reflect.set returns false for both, and the trap's true would contradict it.
The pattern that makes all of these invariants safe to rely on is that Reflect returns exactly the answer the invariant expects. Reflect.ownKeys(target) returns the target's own keys, Reflect.getOwnPropertyDescriptor(target, key) returns the target's own descriptor or undefined, Reflect.set(target, key, value) returns whether the target accepted the write, and Reflect.deleteProperty(target, key) returns whether the delete succeeded. A trap that returns a Reflect result is therefore never in conflict with the target, which is why the idiomatic forwarding proxy is a thin Reflect call per trap and why a trap that returns a hand-written literal is where the invariants bite.
The sixth invariant is about prototypes, and the probe's two cases show the shape precisely. A trap may report any prototype while the target is extensible, because the target can still be moved to match, and the engine permits the report even before the target moves. The moment the target becomes non-extensible, the reported prototype must equal the target's actual prototype, because the target can no longer move. A page that returns a fixed prototype from a trap therefore works until something freezes the target, which is why the failure appears late and only in a code path that seals the object.
Where a trap really changes a value
Two of the probe's traps do more than observe, and both are allowed because the target supports the change:
set-doubled -> target=42
apply-trap -> result=50 object-target=TypeError
The set trap doubled the written 21 to 42 on the target, and the write succeeded because the target is plain and extensible. A trap that changes a value like this is the normal way a proxy computes a derived value, and the invariant does not object because the trap kept the target consistent with its own attributes. The apply trap multiplied a call's result by ten, so calling the proxy with 2 and 3 returned 50 instead of 5, again because the callable target made the arithmetic allowance legal.
The second half of that line is the limit. The same apply trap on a plain object target throws a TypeError, because a proxy is callable only when its target is callable, and an object has no [[Call]] to forward. A page that wants a callable proxy has to start from a function, which is a small structural constraint that a proxy built from an object silently violates.
The construct trap has the same requirement and one extra rule. A proxy over a function can serve new through a construct trap, and the trap must return an object, because a constructor call has to produce one. A trap that returns a primitive throws a TypeError, which is the construct-side twin of the apply-side requirement that the target be callable. A page that wraps a class in a proxy to intercept construction has to make the trap return the instance, and a page that forgets returns undefined and gets the throw rather than a silent instance.
A trap that returns a different object entirely is legal, and that is where a proxy stops being transparent. A get trap that returns a fresh wrapper for an array keeps the invariant, because get has no attribute it must match, and the caller receives the wrapper instead of the stored array. A page that compares proxy.items === target.items then gets false, and a page that expects identity across two reads gets two different wrappers unless the trap caches them. The invariant permits all of that, which is why a record of a proxy's behaviour has to name what the trap returns rather than assume it forwards.
What a revoked proxy and a non-callable target do
A proxy that stops working on use has the same weakness as a collection that drops an entry on its own: weak collections and memory retention shows how a page loses access to a value it thought it still held. Proxy.revocable returns a proxy and a revoke function, and revoking the proxy makes every operation fail:
revoked -> TypeError,TypeError,TypeError
A property read, an in check and an Object.keys call each threw a TypeError after the revoke. That is a stronger failure than deleting the reference, because a page that kept a live reference to the proxy still cannot use it. It is also why a revoked proxy is a useful way to close a capability: the consumer holds the handle, and the handle stops working when the producer revokes it, with no cooperation from the consumer.
Revocation is also irreversible, and the revoke function is idempotent. Calling revoke() a second time does nothing and throws nothing, so a page that guards its cleanup with a flag gains nothing beyond clarity. A page that tries to re-create the proxy from the same target after revoking gets a new, independent proxy, because revocation is attached to the proxy object and not to the target, which is why a revoked proxy cannot be resurrected by wrapping the same target again.
Two details make a revoked proxy hard to detect by inspection. First, typeof on a revoked callable proxy still reports function, so a check that only tests typeof passes a revoked function proxy. Second, the throw happens on use rather than on construction, so a page that builds the proxy during setup and uses it later sees the failure far from the revoke that caused it. Both are the same design: revocation is a runtime state, not a structural one.
A page that wants to detect a proxy rather than use one has a harder job, and the invariants are the reason. A proxy has no own properties of its own, so a structural check that reads Object.getOwnPropertyNames(proxy) sees the target's names, and a check that reads Object.getPrototypeOf(proxy) sees the target's prototype unless the trap reports another. The one signal that survives is the trap's own behaviour: a trap that returns a value the target could not produce is the only observable trace, which is exactly what the invariants constrain and why a well-written forwarding proxy leaves almost none.
Record a proxy reading
A proxy reading supports a narrow claim: this runtime, these traps, this target, produced these observations. Four fields keep it checkable:
- The access type, because each operation has its own trap associated with it.
- The target's own attributes, because a frozen or non-extensible target changes which trap answers are legal.
- The trap answer, because the invariant compares that answer against the target rather than accepting it.
- Whether the proxy was revoked or its target callable, because both change the failure to a
TypeErroron use.
Public sources
- MDN: Proxy
- MDN: Proxy.revocable()
- MDN: Reflect
- ECMAScript: Proxy Objects
- BotBrowser: automation consistency reference
Practical conclusion
Treat a proxy as a filter over a real target: every trap answer is checked against the target's own attributes, and the ones that disagree throw a TypeError on use. Name the operation when a record describes a proxy, because a read and a descriptor lookup reach different traps and only one of them may have fired. Use a function target for a callable proxy and a plain object only for a property proxy, and remember that a revoked proxy keeps its typeof and fails only when used. For the object whose properties the proxy filters, read object key order; for the copy semantics a proxy passes through, read structuredClone versus JSON.
This article is part of the guide: What Is a Browser Fingerprint?
Related Articles
WeakMap and WeakSet: What They Hold and What They Drop
FingerprintAbort Signal Reason Composition and Late Listeners
FingerprintObject Key Order: Integer Keys Sort, String Keys Stay
FingerprintIntl.Collator: Locale Order Is Not Code-Unit Order
FingerprintPromise Combinators: What Fails First
FingerprintArray Sort and Comparators: A Function You Can Get Wrong
Take BotBrowser from research to production
The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.