Pull to refresh

Проверяем, есть ли у нативной JavaScript‑функции манкипатч

Reading time 7 min
Views 6K
Original author: Matteo Mazzarolo

Кратко: как можно понять, была ли переопределена нативная JavaScriptфункция? Никак — или не совсем надежно. Способы есть, но полностью доверять им нельзя.

Нативные функции в JavaScript

В JavaScript «нативная функция» — это функция, исходный код которой был скомпилирован в машинный код. Нативные функции можно найти в стандартных встроенных объектах JavaScript (таких, как eval(), parseInt() и т. д.) и веб-API браузеров (таких, как fetch(), localStorage.getItem() и т. д.).

Из-за динамической природы JavaScript разработчики могут переопределять нативные функции, предоставляемые браузером. Этот метод известен как манкипатчинг.

Манкипатчинг

Манкипатчинг, в основном, используется для изменения поведения встроенных API и нативных функций браузера. Часто, это единственный способ добавить специфичную функциональность, полифиллы или «зацепиться» за API, который иначе изменить невозможно.

Например, инструменты мониторинга, такие как Bugsnag, переопределяют интерфейсы Fetch и XMLHttpRequest, чтобы получить представление о сетевых подключениях, запускаемых кодом JavaScript.

Манкипатчинг — это мощный, но опасный метод, потому что код, который вы переопределяете, не находится под вашим контролем: будущие обновления движка JavaScript могут нарушить предположения, сделанные в вашем патче, и вызвать серьезные ошибки.

Кроме того, делая манкипатчинг кода, которым не владеете, вы можете переопределить код, уже содержащий манкипатч другого разработчика, что приведет к потенциальному конфликту.

По этим (и многим другим) причинам может понадобиться проверить, является ли данная функция нативной или она имеет манкипатч… Но получится ли?

Использование toString() для проверки того, что у функции есть манкипатч

Самый распространенный способ проверить, является ли функция «чистой» (то есть, без манкипатча) — это проверить вывод ее toString().

По умолчанию, нативная функция toString() возвращает что-то вроде строки "function fetch() { [native code] }":

Эта строка может немного отличаться в зависимости от того, какой движок JavaScript используется. Тем не менее в большинстве браузеров вы можете с уверенностью предположить, что эта строка будет включать подстроку "[native code]".

Если нативная функция будет иметь манкипатч, ее toString() перестанет возвращать строку "[native code]" в пользу возврата тела функции в виде строки.

Таким образом, простой способ проверить, является ли функция по-прежнему нативной — это проверить, содержит ли ее вывод toString() строку "[native code]".

Элементарная проверка может выглядеть так:

function isNativeFunction(f) {
  return f.toString().includes("[native code]");
}

isNativeFunction(window.fetch); // → true

// Манкипатч fetch API
(function () {
  const { fetch: originalFetch } = window;
  window.fetch = function fetch(...args) {
    console.log("Вызов fetch перехвачен:", ...args);
    return originalFetch(...args);
  };
})();

window.fetch.toString(); // → "function fetch(...args) {\n console.log("Fetch...

isNativeFunction(window.fetch); // → false

Этот подход отлично работает в большинстве случаев. Однако, его легко обойти, заставив считать, что функция по-прежнему нативна, когда это не так. Будь то злой умысел (например, вредоносное изменение кода) или потому, что кто-то хочет скрыть факт переопределения. Есть несколько способов, которыми вы можете сделать функцию «нативной».

Например, вы можете добавить код (или даже комментарий!) в тело функции, содержащий строку "[native code]":

(function () {
  const { fetch: originalFetch } = window;
  window.fetch = function fetch(...args) {
    // function fetch() { [native code] }
    console.log("Вызов fetch перехвачен:", ...args);
    return originalFetch(...args);
  };
})();

window.fetch.toString(); // → "function fetch(...args) {\n // function fetch...

isNativeFunction(window.fetch); // → true

…Или вы можете переопределить метод toString(), чтобы он возвращал строку, содержащую "[native code]":

(function () {
  const { fetch: originalFetch } = window;
  window.fetch = function fetch(...args) {
    console.log("Вызов fetch перехвачен:", ...args);
    return originalFetch(...args);
  };
})();

window.fetch.toString = function toString() {
  return `function fetch() { [native code] }`;
};

window.fetch.toString(); // → "function fetch() { [native code] }"

isNativeFunction(window.fetch); // → true

…Или вы можете создать функцию с манкипатчем, используя bind, которая генерирует нативную функцию:

(function () {
  const { fetch: originalFetch } = window;
  window.fetch = function fetch(...args) {
    console.log("Вызов fetch перехвачен:", ...args);
    return originalFetch(...args);
  }.bind(window.fetch); // 👈
})();

window.fetch.toString(); // → "function fetch() { [native code] }"

isNativeFunction(window.fetch); // → true

…Или вы сделаете манкипатч функции, перехватив вызовы apply() с помощью ES6 Proxy, что сделает функцию снаружи похожей на нативную:

window.fetch = new Proxy(window.fetch, {
  apply: function (target, thisArg, argumentsList) {
    console.log("Вызов fetch перехвачен:", ...argumentsList);
    Reflect.apply(...arguments);
  },
});

window.fetch.toString(); // → "function fetch() { [native code] }"

isNativeFunction(window.fetch); // → true

Остановимся на этом.

Моя точка зрения такова: разработчики могут легко сделать так, чтобы манкипатч не был заметен, если вы проверяете функцию toString().

Я думаю, зачастую вам не следует заботиться о крайних случаях, описанных выше. Но если захотите сделать это, можете попробовать покрыть их дополнительными проверками.

Например:

  • вы можете использовать одноразовые iframe, чтобы получить «чистое» значение toString() и использовать его в строгом сравнении;

  • вы можете вызвать несколько .toString().toString(), чтобы убедиться, что функция toString() не переопределена;

  • вы можете надеть шляпу метапрограммиста и сделать манкипатч конструктора Proxy, чтобы определить, является ли проксируемой нативная функция (потому что, следуя спецификациям, невозможно определить, является ли что-то Proxy);

  • и т.п.

Все зависит от того, насколько глубоко вы хотите проникнуть в кроличью нору toString().

Но стоит ли? Можно ли охватить все крайние случаи?

Получение чистой функции из iframe

Если вам нужно вызвать «чистую» функцию (вместо того, чтобы проверять, есть ли манкипатч у нативной функции) — можно получить ее из iframe с таким же источником (same-origin):

// Создаем новый iframe с тем же источником.
// Вы, вероятно, захотите добавить некоторые стили для его скрытия и, в конечном итоге, удалить его из DOM позже.
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
// Новый iframe создаст свой собственный "чистый" объект window, так что вы сможете захватить интересующую вас функцию оттуда.
const cleanFetch = iframe.contentWindow.fetch;

Хотя я думаю, что этот подход все же лучше, чем проверка функции с помощью toString(), но он имеет некоторые существенные ограничения:

  • Из-за строгой политики безопасности контента (CSP) или из-за того, что ваш код не работает в браузере, iframe может быть недоступен.

  • Хоть это и маловероятно, другая сторона может сделать манкипатч API iframe. Таким образом, вы все еще не можете на 100% доверять объекту window созданного iframe.

  • Нативные функции, которые манипулируют DOM (например, document.createElement), не будут работать с этим подходом, потому что они будут нацелены на DOM созданного iframe, а не на родительский.

Данное решение было предложено в треде lobstre.rs.

Использование сравнения ссылок для проверки того, что у функции есть манкипатч

Если безопасность является первостепенной задачей, я думаю, следует использовать другой подход: сохранить ссылку на «чистую» нативную функцию и позже сравнить с ней функцию с потенциальным манкипатчем:

<html>
  <head>
    <script>
       // Сохраните ссылку на исходную «чистую» нативную функцию, прежде чем какой-либо другой скрипт сможет ее изменить.
       // В этом случае мы просто храним ссылку на исходный fetch API и прячем ее в замыкании. Если вы не знаете заранее, какой API вы хотите проверить, вам может понадобиться сохранить ссылки на несколько свойств, принадлежащих window.
      (function () {
        const { fetch: originalFetch } = window;
        window.__isFetchMonkeyPatched = function () {
          return window.fetch !== originalFetch;
        };
      })();
       // С этого момента вы можете проверить, был ли манкипатчинг fetch API, вызвав window.__isFetchMonkeyPatched().
       //
       // Пример:
      window.fetch = new Proxy(window.fetch, {
        apply: function (target, thisArg, argumentsList) {
          console.log("Вызов fetch перехвачен:", ...argumentsList);
          Reflect.apply(...arguments);
        },
      });
      window.__isFetchMonkeyPatched(); // → true
    </script>
  </head>
</html>

Используя строгое сравнение ссылок, мы избегаем всех лазеек toString(). Это работает даже при использовании Proxy, потому что оно не может перехватить сравнение 🪤.

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

Могут быть способы сломать этот подход, но на момент написания мне не было известно о них. Дайте знать, если я что-то упустил!

Итак, как понять, была ли переопределена нативная функция JavaScript?

Я могу посчитать на пальцах одной руки, сколько раз мне нужно было проверить, есть ли у функции манкипатч.

Так или иначе, мое мнение (или, лучше, «предположение») касательно этого вопроса заключается в том, что, в зависимости от варианта использования, может и не быть надежного способа определить манкипатч.

  • Если вы контролируете всю веб-страницу, вы можете написать свой код заранее, сохранив ссылки на функции, которые хотите проверить, когда они еще «чисты», и сравнить их позже.

  • В противном случае, если вы можете использовать iframe, вы можете создать скрытый одноразовый iframe и взять оттуда «чистую» функцию — понимая, что вы все еще не можете быть на 100% уверены, что iframe API не имеет манкипатча.

  • Иначе, учитывая динамическую природу JavaScript, вы можете либо использовать простую проверку toString().includes("[native code]") (понимая, что злоумышленники могут легко остаться незамеченными), либо добавить массу проверок безопасности, чтобы покрыть большинство (но не всех) пограничных случаев.

Дальнейшее чтение и связанные ресурсы

Tags:
Hubs:
+18
Comments 1
Comments Comments 1

Articles