Назад в Центр знаний
Отпечатки

Ловушки прокси и инварианты среды выполнения

Один опыт на фиксированных ловушках: какие ловушки срабатывают, какие ответы движок отклоняет с TypeError и что делают отозванный прокси и невызываемая цель.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser поддерживает повтор авторизованной same-origin фикстуры под объявленным профилем и ведёт ограниченную запись о том, какие ловушки установила страница и какие ответы движок отклонил, но он не может заставить прокси нарушить инвариант, который движок обеспечивает, не может сохранить отозванный прокси рабочим и не может превратить ловушку на невызываемой цели в вызов. Этот обзор прогоняет один опыт над девятью фиксированными ловушками и фиксирует ловушку, которая срабатывает при обычном чтении, шесть инвариантов, которые отклоняют правдоподобный ответ ловушки с TypeError, две ловушки, которые действительно преобразуют значение, когда цель это позволяет, что отозванный прокси делает с каждым обращением и почему объектная цель не может ответить на вызов.

Кратко

  • При обычном чтении свойства срабатывает только ловушка get, поэтому опыт печатает order=get:a без обращения к дескриптору.
  • Сообщать меньше ключей, чем есть у нерасширяемой цели, приводит к броску TypeError, поэтому ловушка не может скрыть свойство, которое цель сохранила.
  • Сообщать о неудаляемом свойстве, которого у цели нет, приводит к броску TypeError, поэтому ловушка не может выдумать заблокированное свойство.
  • Возврат true из set для замороженного свойства приводит к броску TypeError и оставляет цель без изменений на 1.
  • Ловушка set, которая пишет прямо в цель, действительно её меняет, поэтому опыт печатает target=42 после удвоения 21.
  • Смена прототипа расширяемой цели разрешена, тогда как та же ловушка на нерасширяемой цели приводит к броску TypeError.
  • Отозванный прокси бросает TypeError при чтении, при проверке in и при Object.keys одинаково.
  • Ловушка apply удваивает работу до 50, тогда как та же ловушка на объектной цели приводит к броску TypeError, потому что цель невызываема.
  • Ловушка deleteProperty не может сообщить неудаляемое свойство как удалённое, и свойство остаётся.

Содержание

Первая панель показывает, что обычное чтение запускает только ловушку get. Вторая перечисляет инварианты, которые бросают TypeError, когда ответ ловушки противоречит нерасширяемой или замороженной цели. Третья показывает ловушку set, удваивающую записанное значение до 42, тогда как перехваченная запись в замороженное свойство бросает. Четвёртая показывает отозванный прокси, бросающий при каждом обращении, и невызываемую цель, отклоняющую ловушку apply.

Какая ловушка срабатывает при обычном чтении

Прокси пересылает операции в ловушки, и каждая операция попадает в определённый их набор. Опыт читает одно свойство и записывает каждую сработавшую ловушку:

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

Сработала только get. Это важно, потому что страница, которая инструментирует прокси, считая вызовы getOwnPropertyDescriptor для обнаружения чтений, не насчитает ничего, а страница, которая ждёт, что has выполняется при каждом чтении, получает сюрприз. Каждая операция это взаимно однозначное отображение в свою ловушку, и чтение самое узкое из них.

То же отображение решает, что наблюдает проверка обнаружения. Опыт для снятия отпечатка, который сравнивает обычный объект с прокси, часто делает это, запуская операции, что вынуждают ловушку, и одно только чтение вынуждает одну; обращение к дескриптору вынуждает другую, и строка опыта показывает, что обе не запускаются одним и тем же выражением. Поэтому прокси, который отвечает на чтение, всё ещё может выглядеть как обычный объект для проверки, которая только читает.

Порядок свойств, который фильтрует прокси, это тот порядок, который порядок ключей описывает для обычного объекта. Полное отображение конечно и его стоит запомнить, потому что каждая запись решает, в какую ловушку попадает данное выражение. get обслуживает чтение свойства, set обслуживает присваивание, has обслуживает оператор in, deleteProperty обслуживает delete, ownKeys обслуживает Object.keys и Reflect.ownKeys, getOwnPropertyDescriptor обслуживает Object.getOwnPropertyDescriptor и внутреннее обращение, которое Object.keys выполняет перед ownKeys, defineProperty обслуживает Object.defineProperty, getPrototypeOf и setPrototypeOf обслуживают две операции над прототипом, isExtensible и preventExtensions обслуживают две операции над расширяемостью, а apply и construct обслуживают вызов и new на вызываемой цели. Страница, которая ждёт одну ловушку от одного выражения, права ровно тогда, когда это выражение есть в списке.

Две из этих записей идут в паре, что даёт порядок, а не один вызов. Object.keys сначала вызывает ownKeys, чтобы получить список кандидатов, а затем для каждого ключа, который намерен сохранить, вызывает getOwnPropertyDescriptor, чтобы проверить, перечисляемо ли свойство. Прокси, который отвечает на ownKeys, но не на getOwnPropertyDescriptor, поэтому всё равно работает, потому что ловушка дескриптора по умолчанию пересылает в цель. Прокси, который отвечает на getOwnPropertyDescriptor неперечисляемым дескриптором для ключа, перечисленного в ownKeys, молча убирает этот ключ из результата, а страница, которая считает вызовы ownKeys, чтобы вывести, сколько свойств открывает прокси, завышает ровно на те ключи, которые отфильтровала.

Те же пары видны в замере, который сравнивает стоимость прокси со стоимостью обычного объекта. Каждая инструментированная операция стоит как минимум вызова ловушки плюс то, что делает ловушка, а парные операции стоят двух вызовов ловушек в одном выражении, поэтому прокси над горячим циклом Object.keys заметно медленнее, чем над горячим циклом чтений. Опыт не может замерить это на девяти операциях, и порядок в обоих случаях одинаков, поэтому разница в стоимости это свойство отображения, а не какого-либо конкретного тела ловушки.

Почему движок отклоняет правдоподобный ответ ловушки

Ловушка может вернуть значение, которое противоречит цели, и движок обеспечивает фиксированный набор инвариантов, которые отклоняют такие ответы. Опыт прогоняет шесть из них, и каждый бросает TypeError:

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

Первый случай это нерасширяемая цель с двумя ключами, где ловушка ownKeys сообщает один, и движок не даёт ловушке скрыть ключ, которым цель всё ещё владеет. Второй случай имеет ловушку, которая сообщает неудаляемое свойство, которого у цели нет, и движок не даёт ловушке выдумать заблокированное свойство. Оба ответа выглядят разумно сами по себе, и оба это точные несоответствия, которые инвариант и существует, чтобы ловить.

Третий случай это тот, что сбивает кэширующий прокси. Замороженная цель плюс ловушка set, возвращающая true, бросает, и цель остаётся без изменений:

set-invariant -> TypeError targetValue=1

Ловушка заявила об успехе, а утверждение, которое проверяет инвариант, это согласуется ли собственное состояние цели. Страница, которая оборачивает замороженный объект конфигурации в прокси для журналирования и всегда возвращает true из set, получает бросок при первой попытке записи, а не тихо проигнорированную запись, что есть правильный исход и тот, которого автор страницы обычно не ждал.

Четвёртая пара о тождестве и удалении:

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

Смена сообщаемого прототипа расширяемой цели разрешена, потому что цель всё ещё может измениться, чтобы соответствовать; та же ловушка на нерасширяемой цели бросает, потому что цель больше не может сдвинуться к заявленному прототипу. Удаление неудаляемого свойства через ловушку, которая возвращает true, бросает, потому что у цели всё ещё есть свойство. В обоих случаях инвариант это одна и та же идея в разном ключе: ловушка не может сообщить состояние объекта, которое запрещают собственные атрибуты цели.

Общее правило за всеми шестью стоит изложить один раз: прокси это фильтр, а не замена. Движок сравнивает каждый ответ ловушки с собственными атрибутами свойств цели, и любой ответ, который потребовал бы от цели нарушить эти атрибуты, отклоняется. Поэтому прокси нельзя использовать, чтобы выдавать замороженный объект за изменяемый, или что у закрытого объекта меньше ключей, или что свойство одновременно присутствует и неудаляемо, когда у цели такого свойства нет.

Есть пятый инвариант, который опыт затрагивает через set, и ещё один, который проще всего увидеть в паре с ним. У инварианта set две половины: ловушка не может сообщить об успехе для свойства, которое цель помечает не записываемым, и ловушка не может сообщить об успехе для свойства, которого у цели нет, пока цель нерасширяема. Замороженный случай опыта это первая половина. Вторая половина появляется, когда ловушка set прокси над закрытым объектом возвращает true для совершенно нового ключа: цель не может расти, поэтому движок бросает, хотя ловушка её и не касалась. Прокси для журналирования, который пересылает каждую запись через Reflect.set, не видит ни одного из них, потому что Reflect.set возвращает false для обоих, и true ловушки противоречил бы этому.

Схема, которая делает все эти инварианты надёжными, в том, что Reflect возвращает ровно тот ответ, которого ждёт инвариант. Reflect.ownKeys(target) возвращает собственные ключи цели, Reflect.getOwnPropertyDescriptor(target, key) возвращает собственный дескриптор цели или undefined, Reflect.set(target, key, value) возвращает, приняла ли цель запись, а Reflect.deleteProperty(target, key) возвращает, удалось ли удаление. Ловушка, которая возвращает результат Reflect, поэтому никогда не вступает в конфликт с целью, и поэтому идиоматичный пересылающий прокси это тонкий вызов Reflect на каждую ловушку, и поэтому инварианты кусают именно там, где ловушка возвращает написанный вручную литерал.

Шестой инвариант о прототипах, и два случая опыта показывают форму точно. Ловушка может сообщить любой прототип, пока цель расширяема, потому что цель всё ещё можно сдвинуть, чтобы соответствовать, и движок разрешает сообщение ещё до того, как цель сдвинется. В тот момент, когда цель становится нерасширяемой, сообщаемый прототип должен совпадать с фактическим прототипом цели, потому что цель больше не может сдвинуться. Страница, которая возвращает фиксированный прототип из ловушки, поэтому работает, пока что-то не заморозит цель, и поэтому сбой появляется поздно и только в ветке кода, которая закрывает объект.

Где ловушка действительно преобразует значение

Две ловушки опыта делают больше, чем наблюдают, и обе разрешены, потому что цель поддерживает изменение:

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

Ловушка set удвоила записанное 21 до 42 в цели, и запись удалась, потому что цель обычная и расширяемая. Ловушка преобразования вроде этой это нормальный способ, которым прокси вычисляет производное значение, и инвариант не возражает, потому что ловушка держала цель согласованной с её собственными атрибутами. Ловушка apply умножила результат вызова на десять, поэтому вызов прокси с 2 и 3 вернул 50 вместо 5, снова потому что вызываемая цель сделала преобразование законным.

Вторая половина этой строки это предел. Та же ловушка apply на обычной объектной цели бросает TypeError, потому что прокси вызываем, только когда вызываема его цель, а у объекта нет [[Call]], чтобы пересылать. Страница, которой нужен вызываемый прокси, должна начать с функции, и это небольшое структурное ограничение, которое прокси, построенный из объекта, молча нарушает.

У ловушки construct то же требование и ещё одно правило. Прокси над функцией может обслуживать new через ловушку construct, и ловушка должна вернуть объект, потому что вызов конструктора должен произвести его. Ловушка, которая возвращает примитив, бросает TypeError, и это двойник на стороне construct требования на стороне apply, чтобы цель была вызываема. Страница, которая оборачивает класс в прокси, чтобы перехватить конструирование, должна заставить ловушку вернуть экземпляр, а страница, которая забывает, возвращает undefined и получает бросок, а не тихий экземпляр.

Преобразование, которое возвращает совсем другой объект, законно, и здесь прокси перестаёт быть прозрачным. Ловушка get, которая возвращает новую обёртку для массива, сохраняет инвариант, потому что у get нет атрибута, которому она должна соответствовать, и вызывающий получает обёртку вместо сохранённого массива. Страница, которая сравнивает proxy.items === target.items, тогда получает false, а страница, которая ждёт тождества между двумя чтениями, получает две разные обёртки, если ловушка их не кэширует. Инвариант допускает всё это, и поэтому запись о поведении прокси должна называть то, что возвращает ловушка, а не предполагать, что она пересылает.

Что делают отозванный прокси и невызываемая цель

Прокси, который перестает работать при использовании, имеет ту же слабость, что и коллекция, которая сама отпускает запись: слабые коллекции и удержание памяти показывает, как страница теряет доступ к значению, которое считала сохраненным. Proxy.revocable возвращает прокси и функцию отзыва, и отзыв прокси заставляет каждую операцию падать:

revoked -> TypeError,TypeError,TypeError

Чтение свойства, проверка in и вызов Object.keys каждый бросил TypeError после отзыва. Это более сильный сбой, чем удаление ссылки, потому что страница, которая сохранила живую ссылку на прокси, всё равно не может его использовать. И поэтому отозванный прокси это полезный способ закрыть возможность: потребитель держит дескриптор, и дескриптор перестаёт работать, когда производитель его отзывает, без всякого содействия потребителя.

Отзыв также необратим, а функция отзыва идемпотентна. Повторный вызов revoke() ничего не делает и ничего не бросает, поэтому страница, которая защищает свою очистку флагом, не выигрывает ничего сверх ясности. Страница, которая пытается заново создать прокси из той же цели после отзыва, получает новый, независимый прокси, потому что отзыв привязан к объекту прокси, а не к цели, и поэтому отозванный прокси нельзя воскресить, снова обернув ту же цель.

Две детали делают отозванный прокси трудно обнаружимым осмотром. Во-первых, typeof на отозванном вызываемом прокси всё ещё сообщает function, поэтому проверка, которая только проверяет typeof, пропускает отозванный функциональный прокси. Во-вторых, бросок происходит при использовании, а не при создании, поэтому страница, которая строит прокси во время настройки и использует его позже, видит сбой далеко от отзыва, который его вызвал. Обе это один и тот же замысел: отзыв это состояние времени выполнения, а не структурное.

У страницы, которая хочет обнаружить прокси, а не использовать его, задача труднее, и инварианты тому причина. У прокси нет собственных свойств, поэтому структурная проверка, которая читает Object.getOwnPropertyNames(proxy), видит имена цели, а проверка, которая читает Object.getPrototypeOf(proxy), видит прототип цели, если ловушка не сообщает другой. Единственный сигнал, который выживает, это поведение самой ловушки: ловушка, которая возвращает значение, которое цель не могла бы произвести, это единственный наблюдаемый след, и именно его ограничивают инварианты, и поэтому хорошо написанный пересылающий прокси почти не оставляет его.

Записывайте показания прокси

Показания прокси поддерживают узкое утверждение: эта среда выполнения, эти ловушки, эта цель дали эти наблюдения. Четыре поля держат их проверяемыми:

  1. Тип доступа, потому что у каждой операции своя ловушка.
  2. Собственные атрибуты цели, потому что замороженная или нерасширяемая цель меняет, какие ответы ловушки законны.
  3. Ответ ловушки, потому что инвариант сравнивает этот ответ с целью, а не принимает его.
  4. Был ли прокси отозван или его цель вызываема, потому что и то и другое меняет сбой на TypeError при использовании.

Публичные источники

Практический вывод

Относитесь к прокси как к фильтру над реальной целью: каждый ответ ловушки проверяется против собственных атрибутов цели, и те, что расходятся, бросают TypeError при использовании. Называйте операцию, когда запись описывает прокси, потому что чтение и обращение к дескриптору попадают в разные ловушки и только одна из них могла сработать. Используйте функциональную цель для вызываемого прокси и обычный объект только для прокси над свойствами, и помните, что отозванный прокси сохраняет свой typeof и падает только при использовании. Об объекте, чьи свойства фильтрует прокси, читайте порядок ключей объекта; о семантике копирования, которую прокси пропускает, читайте structuredClone против JSON.

#прокси#Reflect#Инварианты#ownKeys#Отзываемый Прокси

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.