Хочу поделиться тем, как приватный режим Safari привел к разработке простого ключ-значение хранилища на Node.js с резервным копированием, доступом к данным с определенных доменов и защитой паролем от записи и очистки хранилища.
Все началось с того, что мне дали задачу, реализовать тестовый заказ в веб-приложении, которая встроена через iframe в одном популярном ресурсе.
Задача была решена и работала следующим образом:
- неавторизованный пользователь кликает на магазин (ссылка «_blank»);
- в новом окне отображаются тестовые товары, а в iframe мы перенаправляем пользователя в профиль тестового пользователя и ждем появления данных покупки в localStorage;
- после совершения покупки, данные о ней сохраняем в localStorage (сумма, количество, магазин, время покупки и количество бонусов)
- в 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.
Начнем с основных требований к хранилищу:
- Создание токена
- Обновление токена
- Получение значения из хранилища
- Получение всего хранилища
- Запись данных
- Удаление элемента
- Очистка хранилища
- Получения списка резервных копий
- Восстановление хранилища из резервной копии
Схема токена достаточно простая, выглядит следующим образом:
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 | Установка пароля для записи и удаления |
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’ам.
Автор: Хазрат Гаджикеримов