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



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

Задача была решена и работала следующим образом:

  1. неавторизованный пользователь кликает на магазин (ссылка «_blank»);
  2. в новом окне отображаются тестовые товары, а в iframe мы перенаправляем пользователя в профиль тестового пользователя и ждем появления данных покупки в localStorage;
  3. после совершения покупки, данные о ней сохраняем в localStorage (сумма, количество, магазин, время покупки и количество бонусов)
  4. в iframe при появлении данных тестовой покупки в localStorage, мы отображаем информацию в блоке «история покупок»;

Все работало в большинстве браузеров, и даже в IE11, но только не в Safari, чья политика безопастности (более известный как porno-mode) не разрешала получить доступ к данным localStorage одного и того же домена внутри iframe и снаружи (в новом окне).

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

Поиски привели меня к сервису keyvalue.xyz, он позволяет создавать ключ, записывать и считывать данные. И так я начал для каждого пользователя, который решил попробовать тестовый заказ, создавать токен и передавать его в url параметрах в новое окно, далее при успешном тестовом заказе, записываем данные в хранилище, а уже в iframe переодически запрашивал данные, пока они не появятся.?

Все работало, но тут пришло сообщение от тестировщика, на этот раз она сообщила, что с включенным adblock’ом не работает тестовый заказ. Так и есть, в консоли adblock писал, о том что заблокирован запрос к ресурсу. Я обратился к разработчикам сервиса, с просьбой сделать зеркало, они не ответили, попытался через nginx (proxy_pass) сделать зеркало, тоже не помогло, скорее всего из-за фильтра cloudflare.?

Было не приятно, нужно было выходить из ситуации.

Решил написать простое ключ=значение хранилище подобное localStorage, с бэкапом, доступом с определенного домена, защитой паролем от записи и удобную библиотеку для работы с ним.

Разработка


Написать на Node.js простой rest api c помощью express не составляет труда, для хранения данных я выбрал MongoDB, потому что нет жесткой структуры и изменить структуру документа можно всего лишь одной строчкой кода в схеме и конечно то, что mongodb может работать с документами большого размера (100-200Гб).

Подробно рассказывать о разработке не имеет смысла, она очень проста и большинство из нас уже пользовались фреймворком express.

Начнем с основных требований к хранилищу:

  1. Создание токена
  2. Обновление токена
  3. Получение значения из хранилища
  4. Получение всего хранилища
  5. Запись данных
  6. Удаление элемента
  7. Очистка хранилища
  8. Получения списка резервных копий
  9. Восстановление хранилища из резервной копии

Схема токена достаточно простая, выглядит следующим образом:

const TokenSchema = new db.mongoose.Schema({
  token: { type: String, required: [true, "tokenRequired"] },
  connect: { type: String, required: [true, "connectRequired"] },
  refreshToken: { type: String, required: [true, "refreshTokenRequired"] },
  domains: { type: Array, default: [] },
  backup: { type: Boolean, default: false },
  password: { type: String },
})

Дополнительные параметры:
token Используется для доступа к хранилищу
connect Свойство для связки хранилища с токеном
refreshToken Обновление токена в случаи,
если нужно обновить токен или токен где-то засветился,
например в git коммите
domains Массив доменов, доступ к хранилищу, которым разрешен.
Для проверки используется HTTP заголовок Origin
backup Если установлено true, то каждые 2 часа будет выполнено
резервное копирование всего хранилище,
то есть в течении суток всегда доступно несколько резервных копий,
к которым можно откатиться
password Установка пароля для записи и удаления

Обработчик POST запроса /create
  app.post('/create', async (req, res) => {
    try {
      // Additional storage protection data
      const { domains, backup, password } = req.body
      // New unique uuid token
      const token = uuid.v4()
      // A unique identifier for connecting the token to the storage 
      // as well as using it you can update the token
      const connect = uuid.v1()

      // Default
      const tokenParam = {
        token: token,
        connect: connect,
        refreshToken: connect
      }

      // The list of domains for accessing the repository
      if (domains) {
        // If an array is passed, store it as it is
        if (Array.isArray(domains)) tokenParam.domains = domains
        // If a string is passed, wrap it in an array
        if (typeof domains === 'string') tokenParam.domains = [domains]
        // If passed boolean true, save the host
        if (typeof domains === 'boolean') tokenParam.domains = [req.hostname]
      }

      // Availability of backup
      if (backup) tokenParam.backup = true

      // If a password is sent, we save it
      if (password) tokenParam.password = md5(password)

      // Save to db
      await new TokenModel.Token(tokenParam).save()

      // Sending the token to the client
      res.json({ status: true, data: tokenParam })
    } catch (e) {
      res.status(500).send({ status: false, description: 'Error: There was an error creating the token' })
    }
  })


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

axios.post('https://api.kurtuba.ru/create', {
  domains: ['example.com', 'google.com'],
  backup: true,
  password: 'qwerty'
})

В результате выполнения мы получим ответ с токеном, который можно использовать для записи и чтения данных с хранилища:

{
  "status":  true,
  "data":{
    "token": "002cac23-aa8b-4803-a94f-3888020fa0df",
    "refreshToken": "5bf365e0-1fc0-11e8-85d2-3f7a9c4f742e",
    "domains": ["example.com", "google.com"],
    "backup": true,
    "password": "d8578edf8458ce06fbc5bb76a58c5ca4"
  }
}

Запись данных в хранилище:

axios.post('https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/set', {
  name: 'hazratgs',
  age: 25,
  city: 'Derbent'
  skills: ['javascript', 'react+redux', 'nodejs', 'mongodb']
})

Получение элемента из хранилища:

axios.get('https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/get/name')

В результате мы получим:

{
  "status":  true,
  "data": "hazratgs"
}

Остальные примеры вы можете посмотреть на странице проекта в GitHub..

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

Для удобства доступна JavaScript библиотека и Python библиотека.
Само хранилище доступно по адресу api.kurtuba.ru

Пример работы с библиотекой


И так у нас уже есть хранилище и библиотека для работы с ним, давайте установим библиотеку:

npm i kurtuba-client

Импортируем в проект:

import onlineStorage from 'kurtuba-client'

Хочу заметить, так как мы делаем онлайн имплементацию localStorage, в коде проекта по умолчанию мы возвращаем объект, а не класс, для того, чтобы работать с одним источником данных по всему проекту, если вам нужно несколько объектов, вы можете импортировать сам класс KurtubaStorage и создавать на основе его сколько угодно объектов.

Создаем токен:

onlineStorage.create()

Надо сказать, что onlineStorage асинхронный метод, как практически все методы объекта onlineStorage, поэтому лучшим вариантом будет использовать синтаксис async/await.

После создания токена, он записывается в свойство token и далее подставляется при необходимости, например запись данных:

await onlineStorage.set({
  name: 'hazratgs',
  age: 25,
  city: 'Derbent'
  skills: ['javascript', 'react+redux', 'nodejs', 'mongodb']
})

чтение данных:

const order = await onlineStorage.get('name') // hazratgs

удаление свойства:

await onlineStorage.remove('name')

Теперь можно смело сказать, что у нас есть онлайн имплементация localStorage и даже больше, так localStorage работает только со строками, а наше хранилище работает умеет хранить строки, числа, объекты и логические типы.

Заключение


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

?Используйте её только для малозначимых и общедоступных данных, как в примере у меня, для передачи данных тестовой покупки из окна в iframe.

Проблемы могут возникнуть по причине того, что токен, как и весь javascript мы передаем клиенту и ему не составит труда получить данные всего хранилища, это не проблема конкретно нашего хранилища, любые api-key переданные клиенту, становятся публичными и хотя у нас есть некие защиты в лице работы с определенными доменами и паролями, все это скорее всего можно обойти.

Для того, чтобы спрятать токен, конечно можно написать обертку над api у себя на сервере, но это уже такое, легче уже свою базу данных настроить.

Просьба не ругать сильно, это моя первая публикация и вклад в open source.
Очень буду благодарен в помощи устранения уязвимостей, советам, pull request’ам.

Комментарии (13)


  1. xakepmega
    16.03.2018 23:28

    image


    1. Hazrat Автор
      17.03.2018 23:57

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


  1. NightSilf
    17.03.2018 09:54

    Простите, а Firebase то чем не подошел?


    1. Hazrat Автор
      17.03.2018 23:54

      — платный
      — сложный, нельзя просто выполнить нечто подобное onlineStorage.get('name')
      — одно хранилище, а для моей задачи необходимо для каждого пользователя свое хранилище, ниже объясню почему
      — api key и db link передаются клиенту, если я буду вести список тестовых заказов в одном хранилище, недоброжелатель может просто взять api key и db link и очистить хранилище, в моем случае у каждого своя область видимости
      — опыт, всегда хочется сделать что-то полезное


      1. Bringoff
        18.03.2018 13:47

        платный

        Только под серьезной нагрузкой.


        сложный, нельзя просто выполнить нечто подобное onlineStorage.get('name')

        пишем cloud function в несколько строк, к которой обращаемся с клиента


        api key и db link передаются клиенту, если я буду вести список тестовых заказов в одном хранилище, недоброжелатель может просто взять api key и db link и очистить хранилище, в моем случае у каждого своя область видимости

        настраиваем права доступа для чтения и записи в отдельные части базы.


        опыт, всегда хочется сделать что-то полезное

        ну, тут возразить нечего :) Если хочется, то куда деваться.


        В ощем, картинка с троллейбусом здесь как нельзя кстати.


        1. Hazrat Автор
          18.03.2018 15:00

          — посещаемость ресурса около 600 тыс в день, бесплатный вариант не подходит
          — зачем мне писать «cloud function»? когда за это же время можно собрать свое хранилище?
          — вы видимо не понимаете? запись в хранилище с клиента должна быть доступна! А значит любой может воспользоваться и очистить хранилище

          понимаете, мы долго будем приводить аргументы за и против, но суть в том, что вы не поняли задачу, ее решить с firebase нельзя или сложнее чем использовать просто хранилище, или как минимум написать обертку над api firebase на сервере, что равноценно разработки самого хранилища…


          1. Bringoff
            18.03.2018 15:02

            посещаемость ресурса около 600 тыс в день

            привлечь к этой задачи бэкенд разработчиков для создания какого-либо API для хранения данных разрешения не получил

            Что ж, бывает. Вопросов более не имею.


            1. Hazrat Автор
              18.03.2018 15:21

              ну и чудненько


  1. xytop
    17.03.2018 11:13
    +1

    А я не понимаю проблем с localStorage. Есть ссылка на описание проблемы?
    А вообще, есть ещё postMessage для общения между фреймом и основным сайтом.


    1. Hazrat Автор
      17.03.2018 23:59

      habrahabr.ru/post/349164
      Я конечно не согласен с некоторыми пунктами из статьи.
      На счет postMessage, мы используем этот метод для общения iframe с родительским окном, но ведь я написал в статье, что ссылка из iframe открывается в новой вкладе, и она уже ни как не может обратиться к той вкладке.


    1. Hazrat Автор
      18.03.2018 00:10

      Если вы имеете ввиду, почему к данным localStorage установленным внутри iframe не возможно получить доступ в новой вкладе, то скорее всего из-за параметра по умолчанию «block cookies and other website data» в iOS Safari


  1. rumkin
    18.03.2018 23:59

    Внесу конструктивную критику (в отличие от товарищей выше). Идея хорошо проработана, хорошо оформлен репозиторий. Есть несколько недочетов, некоторые критические:


    1. Пароль слабо защищен (это можно считать уязвимостью). Во-первых, сегодня использование md5 не рекомендуется (объяснение почему), переходите на sha3. Во-вторых, пароль нужно солить, для этого используйте bcrypt. В-третьих, пароль (даже захешированный) никогда не возвращается в ответе.
    2. Сейчас хранилище намертво привязано к express и mongodb, этого можно избежать используя адаптеры. Обычно я делаю нечто подобное:


      const manager = new Manager({db: new MongoAdapter({mongoose}));
      express.use(createManagerRouter(manager));

      Это позволит использовать различные бекенды (CoachDB, LMDB) и фронтенды (Connect, Koa, Hapi). И сделает код модульным. У меня есть пример похожего REST-приложения, разделенного на независимые компоненты. Очень помогло, когда заказчик попросил доработать функционал для MongoDB.


    3. Код тестов не стоит оборачивать в стрелочные функции.
    4. mocha поддерживает промисы.
    5. Методы get/set не стоит добавлять в URL для этого используются POST, GET, DELETE. Относитесь к URL как к ID ресурса.
      POST https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/item {name: 'Username'}
      GET https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/item/name
      DELETE https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/item/name
    6. Добавьте бенчмарки для пользователей, которые на них ориентируются.


    1. Hazrat Автор
      19.03.2018 13:15

      Столько полезной информации в одном лишь комментарии, спасибо, обязательно поправлю!