Volver al Centro de conocimiento
Huella digital

Trampas de proxy y las invariantes que impone el motor

Una sonda sobre trampas fijas: qué trampas disparan, qué respuestas rechaza el motor con un TypeError y qué hace un proxy revocado o no invocable.

BotBrowser Team

Documentación

Quieres la documentación estructurada de Huella digital?

Este artículo forma parte de la biblioteca editorial. Para pasos de configuración, material de referencia y actualizaciones continuas, entra en la sección de docs.

BotBrowser permite reproducir un fixture autorizado del mismo origen bajo un perfil declarado y mantener un registro acotado de qué trampas instaló una página y qué respuestas rechazó el motor, pero no puede hacer que un proxy rompa una invariante que el motor impone, no puede mantener usable un proxy revocado y no puede convertir en llamada una trampa sobre un objetivo no invocable. Esta revisión ejecuta una sonda sobre nueve trampas fijas y registra la trampa que dispara una lectura simple, las seis invariantes que rechazan con un TypeError una respuesta de trampa que parece plausible, las dos trampas que de verdad cambian un valor cuando el objetivo lo permite, qué hace un proxy revocado a cada acceso y por qué un objetivo objeto no puede responder a una llamada.

Resumen

  • Una lectura simple de propiedad dispara solo la trampa get, así que la sonda imprime order=get:a sin búsqueda de descriptor.
  • Informar menos claves de las que posee un objetivo no extensible lanza un TypeError, así que una trampa no puede ocultar una propiedad que el objetivo conservó.
  • Informar una propiedad no configurable que el objetivo no tiene lanza un TypeError, así que una trampa no puede inventar una propiedad bloqueada.
  • Devolver true desde set para una propiedad congelada lanza un TypeError y deja el objetivo sin cambios en 1.
  • Una trampa set que escribe sobre el objetivo sí lo cambia, así que la sonda imprime target=42 tras duplicar 21.
  • Cambiar el prototipo de un objetivo extensible está permitido, mientras la misma trampa sobre un objetivo no extensible lanza un TypeError.
  • Un proxy revocado lanza un TypeError tanto en una lectura como en una comprobación in y en Object.keys.
  • Una trampa apply duplica el trabajo hasta 50, mientras la misma trampa sobre un objetivo objeto lanza un TypeError porque el objetivo no es invocable.
  • Una trampa deleteProperty no puede informar una propiedad no configurable como borrada, y la propiedad permanece.

Contenido

Un primer panel muestra una lectura simple que dispara solo la trampa get. Un segundo enumera las invariantes que lanzan un TypeError ante un objetivo no extensible o congelado. Un tercero muestra una trampa set que duplica un valor hasta 42, mientras otra escritura lanza. Un cuarto muestra un proxy revocado que lanza en cada acceso y un objetivo no invocable que rechaza apply.

Qué trampa dispara una lectura simple

Un proxy reenvía operaciones a trampas, y cada operación alcanza un conjunto específico de ellas. La sonda lee una propiedad y registra cada trampa que se ejecutó:

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

Solo se disparó get. Eso importa porque una página que instrumenta un proxy contando las llamadas a getOwnPropertyDescriptor para detectar lecturas no cuenta nada, y una página que espera que has se ejecute en cada lectura se lleva una sorpresa. Cada operación es una correspondencia uno a uno con su trampa, y una lectura es la más estrecha.

La misma correspondencia decide qué observa una comprobación de detección. Una sonda de fingerprinting que compara un objeto plano con un proxy a menudo lo hace al disparar operaciones que fuerzan una trampa, y una lectura por sí sola fuerza una; una búsqueda de descriptor fuerza otra distinta, y la línea de la sonda muestra que ambas no las dispara la misma sentencia. Por eso un proxy que responde a una lectura todavía puede parecer un objeto plano ante una comprobación que solo lee.

La correspondencia completa es finita y vale la pena memorizarla, porque cada entrada decide qué trampa alcanza una sentencia dada. get sirve una lectura de propiedad, set sirve una asignación, has sirve el operador in, deleteProperty sirve delete, ownKeys sirve Object.keys y Reflect.ownKeys, getOwnPropertyDescriptor sirve Object.getOwnPropertyDescriptor y la búsqueda interna que Object.keys realiza antes de ownKeys, defineProperty sirve Object.defineProperty, getPrototypeOf y setPrototypeOf sirven las dos operaciones de prototipo, isExtensible y preventExtensions sirven las dos operaciones de extensibilidad, y apply y construct sirven una llamada y un new sobre un objetivo invocable. Una página que espera una trampa a partir de una sentencia acierta exactamente cuando esa sentencia está en la lista.

Dos de esas entradas se emparejan, lo que produce un orden en lugar de una sola llamada. Object.keys primero invoca ownKeys para obtener la lista candidata y luego, para cada clave que pretende conservar, invoca getOwnPropertyDescriptor para comprobar si la propiedad es enumerable. Por eso un proxy que responde a ownKeys pero no a getOwnPropertyDescriptor sigue funcionando, porque la trampa de descriptor por defecto reenvía al objetivo. Un proxy que responde a getOwnPropertyDescriptor con un descriptor no enumerable para una clave que listó en ownKeys descarta en silencio esa clave del resultado, y una página que cuenta llamadas a ownKeys para inferir cuántas propiedades expone un proxy cuenta de más exactamente las claves que filtró.

Las mismas parejas aparecen en un benchmark que compara el costo de un proxy con el de un objeto plano. Cada operación instrumentada cuesta al menos la llamada a la trampa más lo que la trampa haga, y las operaciones emparejadas cuestan dos llamadas a trampa en una sentencia, por lo que un proxy sobre un bucle caliente de Object.keys es mediblemente más lento que uno sobre un bucle caliente de lecturas. La sonda no puede cronometrar eso sobre nueve operaciones, y el orden es idéntico en ambos casos, así que la diferencia de costo es una propiedad de la correspondencia más que de cualquier cuerpo de trampa concreto.

Por qué el motor rechaza una respuesta de trampa plausible

Una trampa puede devolver un valor que contradice al objetivo, y el motor impone un conjunto fijo de invariantes que rechazan esas respuestas. La sonda acciona seis de ellas y todas lanzan un TypeError:

ownKeys-invariant -> TypeError
gopd-invariant -> TypeError

El primer caso es un objetivo no extensible con dos claves donde la trampa ownKeys informa una, y el motor se niega a permitir que una trampa oculte una clave que el objetivo todavía posee. El segundo caso tiene una trampa que informa una propiedad no configurable que el objetivo no tiene, y el motor se niega a permitir que una trampa invente una propiedad bloqueada. Ambas respuestas parecen razonables por separado, y ambas son las discordancias exactas que la invariante existe para cazar.

El tercer caso es el que hace tropezar a un proxy de caché. Un objetivo congelado más una trampa set que devuelve true lanza, y el objetivo queda sin cambios:

set-invariant -> TypeError targetValue=1

La trampa afirmó éxito, y la aserción que comprueba la invariante es si el propio estado del objetivo concuerda. Una página que envuelve un objeto de configuración congelado en un proxy de registro y siempre devuelve true desde set recibe un lanzamiento en el primer intento de escritura en lugar de una escritura ignorada en silencio, que es el resultado correcto y el que el autor de la página por lo general no esperaba.

El cuarto par trata de identidad y borrado:

prototype-invariant -> extensible=allowed non-extensible=TypeError
delete-invariant -> TypeError stillThere=true

Cambiar el prototipo informado de un objetivo extensible está permitido, porque el objetivo todavía puede cambiar para coincidir; la misma trampa sobre un objetivo no extensible lanza, porque el objetivo ya no puede moverse al prototipo declarado. Borrar una propiedad no configurable mediante una trampa que devuelve true lanza, porque el objetivo todavía tiene la propiedad. En ambos casos la invariante es la misma idea en una clave distinta: una trampa no puede informar un estado del objeto que los propios atributos del objetivo prohíben.

La regla general detrás de las seis merece enunciarse una vez: un proxy es un filtro, no un reemplazo. El motor compara cada respuesta de trampa contra los atributos de propiedad del propio objetivo, y cualquier respuesta que exigiría que el objetivo violara esos atributos se rechaza. Por eso un proxy no puede usarse para fingir que un objeto congelado es mutable, o que un objeto sellado tiene menos claves, o que una propiedad está presente y no configurable cuando el objetivo no tiene tal propiedad.

Hay una quinta invariante que la sonda ejercita a través de set y una más que es más fácil de ver como pareja con esa. La invariante de set tiene dos mitades: una trampa no puede informar éxito para una propiedad que el objetivo marca como no escribible, y una trampa no puede informar éxito para una propiedad que el objetivo no tiene mientras el objetivo es no extensible. El caso congelado de la sonda es la primera mitad. La segunda mitad aparece cuando la trampa set de un proxy sobre un objeto sellado devuelve true para una clave nueva: el objetivo no puede crecer, así que el motor lanza aunque la trampa nunca lo tocó. Un proxy de registro que reenvía cada escritura con Reflect.set nunca ve ninguna de las dos, porque Reflect.set devuelve false para ambas, y el true de la trampa lo contradiría.

El patrón que hace seguras de usar a todas estas invariantes es que Reflect devuelve exactamente la respuesta que la invariante espera. Reflect.ownKeys(target) devuelve las claves propias del objetivo, Reflect.getOwnPropertyDescriptor(target, key) devuelve el descriptor propio del objetivo o undefined, Reflect.set(target, key, value) devuelve si el objetivo aceptó la escritura, y Reflect.deleteProperty(target, key) devuelve si el borrado tuvo éxito. Por eso una trampa que devuelve un resultado de Reflect nunca está en conflicto con el objetivo, que es la razón por la que el proxy idiomático de reenvío es una llamada fina a Reflect por trampa y por la que una trampa que devuelve un literal escrito a mano es donde las invariantes muerden.

La sexta invariante trata de prototipos, y los dos casos de la sonda muestran la forma con precisión. Una trampa puede informar cualquier prototipo mientras el objetivo es extensible, porque el objetivo todavía puede moverse para coincidir, y el motor permite el informe incluso antes de que el objetivo se mueva. En el momento en que el objetivo pasa a no extensible, el prototipo informado debe ser igual al prototipo real del objetivo, porque el objetivo ya no puede moverse. Por eso una página que devuelve un prototipo fijo desde una trampa funciona hasta que algo congela el objetivo, que es la razón por la que el fallo aparece tarde y solo en una ruta de código que sella el objeto.

Dónde una trampa de verdad cambia un valor

Dos de las trampas de la sonda hacen más que observar, y ambas están permitidas porque el objetivo admite el cambio:

set-doubled -> target=42
apply-trap -> result=50 object-target=TypeError

La trampa set duplicó el 21 escrito hasta 42 en el objetivo, y la escritura tuvo éxito porque el objetivo es plano y extensible. Una trampa que cambia un valor como esta es la forma normal en que un proxy calcula un valor derivado, y la invariante no se opone porque la trampa mantuvo el objetivo consistente con sus propios atributos. La trampa apply multiplicó por diez el resultado de una llamada, así que llamar al proxy con 2 y 3 devolvió 50 en lugar de 5, de nuevo porque el objetivo invocable hizo legal ese cálculo.

La segunda mitad de esa línea es el límite. La misma trampa apply sobre un objetivo objeto plano lanza un TypeError, porque un proxy es invocable solo cuando su objetivo es invocable, y un objeto no tiene [[Call]] que reenviar. Una página que quiere un proxy invocable tiene que partir de una función, que es una pequeña restricción estructural que un proxy construido desde un objeto viola en silencio.

La trampa construct tiene el mismo requisito y una regla extra. Un proxy sobre una función puede servir new mediante una trampa construct, y la trampa debe devolver un objeto, porque una llamada de constructor tiene que producir uno. Una trampa que devuelve un primitivo lanza un TypeError, que es el gemelo del lado de construct del requisito del lado de apply de que el objetivo sea invocable. Una página que envuelve una clase en un proxy para interceptar la construcción tiene que hacer que la trampa devuelva la instancia, y una página que lo olvida devuelve undefined y recibe el lanzamiento en lugar de una instancia silenciosa.

Qué conserva una copia del objeto que envuelve un proxy es la pregunta que responde structuredClone frente a JSON. Una trampa que devuelve un objeto completamente distinto es legal, y ahí es donde un proxy deja de ser transparente. Una trampa get que devuelve un envoltorio nuevo para un array mantiene la invariante, porque get no tiene atributo que deba coincidir, y el llamador recibe el envoltorio en lugar del array almacenado. Una página que compara proxy.items === target.items entonces obtiene false, y una página que espera identidad entre dos lecturas obtiene dos envoltorios distintos a menos que la trampa los almacene en caché. La invariante permite todo eso, que es la razón por la que un registro del comportamiento de un proxy tiene que nombrar qué devuelve la trampa en lugar de suponer que reenvía.

Qué hacen un proxy revocado y un objetivo no invocable

Un proxy que deja de funcionar al usarse tiene la misma debilidad que una colección que suelta una entrada por su cuenta: colecciones débiles y retención de memoria muestra cómo una página pierde acceso a un valor que creía retener. Proxy.revocable devuelve un proxy y una función de revocación, y revocar el proxy hace que toda operación falle:

revoked -> TypeError,TypeError,TypeError

Una lectura de propiedad, una comprobación in y una llamada a Object.keys lanzaron cada una un TypeError tras la revocación. Es un fallo más fuerte que borrar la referencia, porque una página que conservó una referencia viva al proxy todavía no puede usarlo. También es la razón por la que un proxy revocado es una forma útil de cerrar una capacidad: el consumidor tiene el asa, y el asa deja de funcionar cuando el productor la revoca, sin cooperación del consumidor.

La revocación también es irreversible, y la función de revocación es idempotente. Llamar a revoke() una segunda vez no hace nada y no lanza nada, así que una página que protege su limpieza con una bandera no gana nada más allá de claridad. Una página que intenta recrear el proxy desde el mismo objetivo después de revocar obtiene un proxy nuevo e independiente, porque la revocación está unida al objeto proxy y no al objetivo, que es la razón por la que un proxy revocado no puede resucitarse envolviendo el mismo objetivo otra vez.

Dos detalles hacen que un proxy revocado sea difícil de detectar por inspección. Primero, typeof sobre un proxy invocable revocado todavía informa function, así que una comprobación que solo prueba typeof acepta un proxy de función revocado. Segundo, el lanzamiento ocurre al usarse y no al construirse, así que una página que construye el proxy durante la configuración y lo usa después ve el fallo lejos de la revocación que lo causó. Ambos son el mismo diseño: la revocación es un estado en tiempo de ejecución, no uno estructural.

Una página que quiere detectar un proxy en lugar de usarlo tiene un trabajo más difícil, y las invariantes son la razón. Un proxy no tiene propiedades propias, así que una comprobación estructural que lee Object.getOwnPropertyNames(proxy) ve los nombres del objetivo, y una comprobación que lee Object.getPrototypeOf(proxy) ve el prototipo del objetivo a menos que la trampa informe otro. La única señal que sobrevive es el comportamiento de la propia trampa: una trampa que devuelve un valor que el objetivo no podría producir es la única huella observable, que es exactamente lo que las invariantes restringen y por lo que un proxy de reenvío bien escrito casi no deja ninguna.

Registra una lectura de proxy

Una lectura de proxy sostiene una afirmación estrecha: este entorno, estas trampas, este objetivo, produjeron estas observaciones. Cuatro campos la mantienen verificable:

  1. El tipo de acceso, porque cada operación tiene su propia trampa asociada.
  2. Los atributos propios del objetivo, porque un objetivo congelado o no extensible cambia qué respuestas de trampa son legales.
  3. La respuesta de la trampa, porque la invariante compara esa respuesta contra el objetivo en lugar de aceptarla.
  4. Si el proxy estaba revocado o su objetivo era invocable, porque ambos cambian el fallo a un TypeError al usarse.

Fuentes públicas

Conclusión

Trata un proxy como un filtro sobre un objetivo real: cada respuesta de trampa se comprueba contra los atributos propios del objetivo, y las que no concuerdan lanzan un TypeError al usarse. Nombra la operación cuando un registro describe un proxy, porque una lectura y una búsqueda de descriptor alcanzan trampas distintas y solo una de ellas puede haber disparado. Usa un objetivo de función para un proxy invocable y un objeto plano solo para un proxy de propiedades, y recuerda que un proxy revocado conserva su typeof y falla solo al usarse. Para el objeto cuyas propiedades filtra el proxy, lee orden de claves de objeto; para las semánticas de copia que un proxy deja pasar, lee structuredClone frente a JSON.

#proxy#Reflect#Invariantes#ownKeys#Proxy Revocable

Este artículo forma parte de la guía: ¿Qué es la huella digital del navegador?

Lleva BotBrowser de la investigación a producción

Usa estas guías para entender el modelo y después avanzar hacia validación multiplataforma, contextos aislados y despliegue de navegador preparado para escalar.