Защищаем REST API от парсинга генерируемыми токенами

Исходные данные

Итак, предположим у нас есть на фронте React.js, на бэке соответственно DRF. Либо другие аналоги. API бэкенда полностью открыто — как для нашего фронта, так и открыто для postman, scrapy и т.п. Также у нас есть информация, что используя наше же api — конкуренты активно парсят цены, остатки и т.п. Можем ли мы им это запретить? — Не думаю. А вот усложнить им жизнь и развлечься за деньги заказчика сделать это интересным образом — вполне.

0b7808c0ee5d2bc360b3f91b778b44fc.jpg

Поиск решения

Сразу возникает мысль воткнуть на фронт какой-то токен и дальше со стороны сервера проверять его наличие. Однако разрабы конкурентов вполне способны нажать F12 на сайте, найти этот токен в куках или заголовках запросов и добавить к себе в код парсера — который точно также будет передавать этот же токен.

Вот если бы токен менялся сам по себе постоянно. Что же у нас меняется само по себе?

Так это же время!

Возможно же передавать с фронта время в которое был сделан запрос, а на бэкенде проверять время — когда этот запрос был получен. Кто — то скажет:

— А как же часовые пояса?

А я отвечу:

— Будем использовать timestamp.

Соответственно если в куках/загловках нам приходит с фронта, что запрос был сделан больше чем ±10 минут — значит что-то тут нечисто. Время 10 минут выбрано чисто для примера, думаю вполне можно использовать и 5 минут и 3 минуты и т.д.

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

И тут нам приходит на помощь шифрование. Будем использовать симметричное шифрование.

Если мы будем использовать зашифрованный timestamp — который будет генерироваться на фронте реакта, а дешифроваться на бэкенде — то это вполне нас устроит.

Глянем что там у нас есть из существующих алгоритмов. Зашифруем с помощью них пару близких к друг другу timestamp

  • AES

    1640785027 => U2FsdGVkX19OVF5IzcrdnGxJIlenezRUNeqyGuCcHU0=

    1640785140 => U2FsdGVkX19JULnlP8u/ui6LcLYXBW3txJgHNL183DM=

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

В общем было бы здорово, если бы от изменения считай 1- 2 секунд значение выглядело абсолютно по-другому. Да и учитывая, что код будет по шифрованию на фронте, понятно что минифицированный и обфусцированный. Защита алгоритмом еще никому не помешала.

Что-то получилось?

Пример зашифрованных timestamp

Получилось, достаточно чтобы сбить столку и вывести из строя парсеры конкурентов

1640785595 => 171.148.245.238.228

1640785608 => 216.129.188.192.174

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

Мы можем представить наш timestamp — в виде

16

40

78

55

95

Где каждая ячейка может принимать значения [00; 99]. Понятно, что в первой ячейке уже всегда будут значения >= 16 -, но это никак не повлияет на нас.

Когда я взглянул на эти цифры — чем-то они напомнили мне коды символов. И это как раз то что нам нужно. Сгенерируем таблицу шифрования, где ключи в диапазоне [00; 99], а значения уникальные символы. Таблица дешифрования получается — если в таблица шифрования менять местами ключи с значениями.

from random import randint

def generate_decode_table():
    decode_table = {}
    for i in range(100):
        symbol = chr(randint(128, 254))
        while symbol in decode_table:
            symbol = chr(randint(128, 254))
        decode_table[symbol] = str(i) if i > 9 else f'0{i}'
    return decode_table


decode_table = generate_decode_table()
encode_table = {v: k for k, v in decode_table.items()}

Получим таблицу для фронта вида:

let encodeTable = {"00":"ú","01":"ï","02":"", ... '98': '®', '99': '£'};

И обратную таблицу для дешифровки на бэкенде вида:

decode_table = {'£': '99', ... 'ú': '00', 'û': '78', 'ü': '15','ý': '66'}

Чтобы изменения секунд влияли на весь шифр начнем шифровать именно с них. Развернем нашу строку:

95

55

78

40

16

Будем шифровать ячейки начиная с 95. На это значение влияет только оно само.

Ищем его в шифровочной таблице — получаем '«'

Получаем код символа 171. Это первое число нашего шифра.

Далее шифруем 55. На это значение также влиет пришлое 95. Суммируем эти числа:

55 + 95 = 150. Наша таблица шифрования ограниченная значениями [00;99]. Поэтому возьмем остаток от деления 150 % 100 = 50 в таблице шифрования это '\x94' код это символа 148.

Далее по аналогии шифруем оставшиеся числа. Единственное учитываем момент, что после остатка от деления может быть число < 10 и тогда нужно будет добавать впереди символ нуля, например '09'

function generateEncodedTimestamp() {
    let ts = Math.floor(Date.now() / 1000).toString();
    let pairsToEncode = [parseInt(ts.slice(8, 10)), parseInt(ts.slice(6, 8)), parseInt(ts.slice(4, 6)), parseInt(ts.slice(2, 4)), parseInt(ts.slice(0, 2))];
    let encodedPairs = [];
    pairsToEncode.forEach((el, i) => {
        let encodedSum = pairsToEncode.slice(0, i).reduce((a, b) => a + b, 0);
        let keyForTable = ((el + encodedSum) % 100).toString();
        keyForTable = keyForTable.length > 1 ? keyForTable : `0${keyForTable}`;
        encodedPairs.push(encodeTable[keyForTable].charCodeAt(0));
    });
    return encodedPairs.join('.');
}

Этот зашифрованный timestamp вида 171.148.245.238.228

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

На бэкенде же дешифрование выполняется в обратном порядке.

  1. Достаем токен из заголовком к примеру

  2. Получаем символ по коду с помощью chr

  3. Если в таблице дешифрования такого символа нет — то это парсер

  4. Достаем символу в таблице число и вычитаем от него предыдущую сумму. Не забываем обработать случай, если сумма больше данного числа

  5. Берем остаток от деления на 100, добавляем символ нуля в начале при необходимости.

  6. Все соединяем, разворачиваем и у нас получился timestamp от фронта

POSSIBLE_DIFF_EPOCH = 300

def is_robot():
    seconds_from_epoch_server = int(time.time())
    # client_secret = self.request.headers.get('X-VERSION')
    client_secret = '172.154.130.251.208'
    if client_secret is None:
        return True
    symbols_for_codes = [chr(int(el)) for el in client_secret.split('.')]
    reverse_result = []
    for index, symbol in enumerate(symbols_for_codes):
        try:
            encoded_pair = decode_table[symbol]
        except KeyError:
            return True
        if index == 0:
            reverse_result.append(encoded_pair)
            continue
        previous_sum = sum([int(el) for el in reverse_result])
        int_encoded_pair = int(encoded_pair)
        if int_encoded_pair < previous_sum:
            sum_before_division = (previous_sum // 100 + 1) * 100 + int_encoded_pair
        else:
            sum_before_division = int_encoded_pair
        current_pair = str((sum_before_division - previous_sum) % 100)
        if len(current_pair) < 2:
            current_pair = f'0{current_pair}'
        reverse_result.append(current_pair)
    seconds_from_epoch_client = int(''.join(reversed(reverse_result)))
    if abs(seconds_from_epoch_server - seconds_from_epoch_client) > POSSIBLE_DIFF_EPOCH:
        return True
    return False

Ну, а дальше когда знаем парсер это или нет можно как просто возвращать какую-то ерунду, так и добавить к ценам к примеру некий коэффициент.

Главное код на фронте хорошенько запутать. Благо сейчас достаточно сервисов по обфускации. А даже если код прогнать обратно и деобфускацировать — то не зная алгоритма с ходу маловероятно разобраться как генерируются токен.

Быть может кому-то будет полезен мой подход и предложенный вариант реализации.

© Habrahabr.ru