- Регистрация
- 23.09.18
- Сообщения
- 12.347
- Реакции
- 176
- Репутация
- 0

Команде разработчиков, создающей одностраничное приложение (
You must be registered for see links
), рано или поздно придётся столкнуться с ограничениями браузерной безопасности. С одной стороны, нужно сделать так, чтобы фронтенд-сторона могла беспрепятственно общаться с бэкенд API-сервером, а с другой — защитить такое общение от злоумышленников. Сложности начинаются, когда фронтенд и бэкенд находятся на разных доменах, так как на такое взаимодействие браузер накладывает более строгие правила.В клиентском HTML-JS приложении браузер выполняет важную роль «инспектора» внешних запросов и содержит в арсенале мощные инструменты. Наша задача — установить правила, по которым он будет применять эти инструменты к нашему приложению.
Я — разработчик в хостинг-провайдере FirstVDS. При создании SPA для одного из наших проектов я искал решения и применял их на практике, чтобы подружить фронтенд с API и обезопасить их общение. В этой статье я собрал свои мысли и опыт воедино, чтобы поделиться с вами.
От чего надо защищаться?
Потенциально веб-приложение находится под угрозой множества атак. В статье я не буду затрагивать угрозы, направленные только на бэкенд (SQL-инъекции, брутфорс, DDoS и прочие). Рассмотрим атаки, направленные на уязвимости в архитектуре фронтенда, которые реализуются через действия введённого в заблуждение пользователя в браузере. Особенно стоит выделить два больших класса атак: XSS и CSRF (XSRF), породивших за пару десятилетий множество подвидов.
You must be registered for see links
. Вид атак, внедряющих вредоносный код в страницу, которую просматривает пользователь. Такой код может быть внедрён в JS, HTML или даже CSS содержимое. Цели могут быть самые разные — от показа рекламных баннеров до кражи конфиденциальной информации. Внедрение кода становится возможным через провокацию ничего не подозревающего пользователя к вредоносным действиям или через уязвимости самого приложения. Так, самая распространённая XSS-атака основана на отражённой уязвимости. Пользователю, чаще по email, отправляется подготовленная ссылка с тэгом вредоносного скрипта в аргументе. Если сайт, на который она ведет, допускает вывод этого аргумента на странице, то браузер запустит скрипт, и на сайте приложения появится рекламный баннер конкурента. Другие примеры XSS-атак можно посмотреть
You must be registered for see links
.
You must be registered for see links
. Чаще всего использует cookie другого хоста. Например, пользователю в социальной сети приходит сообщение, что он выиграл 10000000 рублей, чтобы их получить, надо только перейти по ссылке. Затем делается POST-запрос в сервис, где пользователь авторизован, в нашем примере — социальная сеть. В результате запрос выполняется с сессионными cookies пользователя и при отсутствии мер защиты такой запрос достигает цели — на странице жертвы начинают появляться посты с сомнительным содержимым. Как еще злоумышленники реализуют эти атаки, можно посмотреть в
You must be registered for see links
.Конечно, список атак намного шире и постоянно пополняется. Хорошее описание для них есть на сайте проекта обеспечения безопасности веб-приложений
You must be registered for see links
.Чтобы обезопасить пользователей и обезоружить злоумышленников, браузер берёт значительную часть работы на себя. Как правило, браузерные политики безопасности управляются заголовками со стороны-веб серверов. Одни заголовки нужны на сервере API-бэкенда, другие на стороне веб-сервера, отдающего статику. Подробнее рассмотрим особенности этих заголовков и других мер безопасности.
CORS
Одностраничное приложение для взаимодействия с сервером использует XHR-запросы. Браузер накладывает на такие запросы особые политики. Если HTML-документ фронтенда и публичный интерфейс бэкенда доступны по одному хосту (протокол, домен, порт), то браузер рассматривает запрос в рамках принципа одного источника Same Origin Policy. Это означает, что он не будет препятствовать такому общению.
В принципе, проектируя приложение, можно работать в рамках этой концепции: настроить Nginx как входной веб-сервер, проксирующий запросы к одному домену по URI. В этом случае для браузера клиент и сервер будут находиться на одном домене, хотя фактически могут быть расположены на разных серверах.
server {
listen 443;
server_name service.test;
location /api {
proxy_pass http://backend.test;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
proxy_pass http://frontend.test;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
Однако не всегда такой путь подойдёт. Бывает необходимость, чтобы фронтенд и API были на разных доменах, например, когда у вас несколько фронтенд-приложений на разных доменах, которые обращаются к одному API, либо политика доменных имён компании диктует свои условия. XHR-запрос с домена источника на сервер с другим доменом браузер пользователя рассматривает в рамках политики Cross-Origin Resource Sharing, или просто CORS. На такое общение браузер накладывает ряд строгих правил, основная цель которых — не дать злоумышленнику отправлять запросы с неразрешенных источников. Разработчику важно понимать, что обычный пользователь в обычной ситуации не должен столкнуться с ограничениями CORS. Ограничения начинают работать, когда механизм взаимодействия «клиент-сервер» настроен неправильно, либо когда пользователь — это злоумышленник, и пытается совершить злодейские деяния. Например, он может отправлять XHR-запросы со своего сайта, используя cookie пользователя (CSRF-атака).
Для большинства запросов настройка, разрешающая CORS, требуется только со стороны веб-сервера. Рассмотрим на примере Nginx:
server {
listen 443;
server_name api.service.test;
location / {
proxy_pass api;
add_header 'Access-Control-Allow-Origin' 'https://service.test';
}
}
Заголовок Access-Control-Allow-Origin сообщает браузеру, какие источники могут взаимодействовать с сервером. Работает это так: браузер посылает предварительный запрос OPTIONS с источника, проверяет, соответствует ли источник заголовку Access-Control-Allow-Origin и только потом отсылает основной запрос. Заголовок может принимать значение ‘*’, что значит «любой». В этом случае браузер пропустит любой кроссдоменный XHR-запрос к этому серверу. Делать это допускается в случае, когда вы проектируете публичное API, оперирующее обезличенными и не конфиденциальными данными. Например, справочник геоданных или сервис статистики посещений. На практике, такие случаи крайне редкие, в большинстве ситуаций Access-Control-Allow-Origin должен иметь значение конкретного источника.
Ещё раз отметим, что CORS-политики — это зона ответственности исключительно браузера. Если API открыто в мир, то допускается его использование с приложений, не являющимися веб-браузером. Например, с бэкенд приложений других серверов: для них заголовок Access-Control-Allow-Origin не будет значить ровным счетом ничего, поэтому за доступность API можно не беспокоиться.
Заголовки безопасности фронтенд веб-сервера
Мы рассмотрели заголовок Access-Control-Allow-Origin, который нужен в первую очередь на сервере бэкенда. Веб-сервер фронтенда, отвечающий за то, чтобы донести HTML-документ и всю сопутствующую статику, тоже требует настройки безопасности. Много статей написано по поводу заголовков безопасности веб-сервера, поэтому коснусь лишь основных, которые нельзя не упомянуть.
X-Frame-Options
Заголовок, ограничивающий открытие вашей страницы в iFrame. В большинстве случаев ваше приложение не будет иметь функциональности, требующей открытия в iFrame другого ресурса. Поэтому можете ставить
location / {
add_header X-Frame-Options deny;
}
и не вспоминать про атаки типа
You must be registered for see links
. Если же вам нужна функция открытия в iFrame, то следует изучить этот вопрос подробнее. Функциональность заголовка X-Frame-Options была реализована в заголовке Content Security Policy, о котором поговорим ниже.X-Content-Type-Options
Заголовок, призванный бороться с атаками типа
You must be registered for see links
. Атака несёт угрозу, когда ваше приложение позволяет загружать файлы на сервер с последующим получением к ним доступа по ссылке. Злоумышленник может подложить HTML-файл под видом картинки без указания MIME-типа или с измененным MIME-типом, например на text/html. А там может быть уже XSS-код. Атаку сложно внедрить в приложения, которые работают в рамках CORS, так как хост источника и сервера различаются. Заголовок X-Content-Type-Options со значением ‘nosniff’ запрещает браузеру самому определять MIME-тип. Установить этот заголовок — лишним не будет и на стороне веб-сервера бэкенда, и на стороне фронтенда.location /upload {
add_header X-Content-Type-Options nosniff;
}
Strict-Transport-Security
При первом посещении сайта клиентом веб-сервер может сообщить браузеру, что открываемый ресурс должен загружаться только в рамках механизма
You must be registered for see links
, то есть только через https. Дальнейшие действия пользователя, даже введённого в заблуждение злоумышленником, который пытается перенаправить трафик на другой ресурс или протокол, например, через недобросовестный Wi-Fi, будут пресекаться на месте. До следующего обновления рекомендованное время действия — 1 год.location / {
add_header Strict-Transport-Security "max-age=31536000";
}
Content Security Policy
Ещё один заголовок, о котором стоит рассказать отдельно, — Content Security Policy. Это очень мощный инструмент, который соединил в себе другие заголовки безопасности, такие как
You must be registered for see links
или x-frame-options. С его помощью можно «попросить» браузер ограничить список ресурсов, допущенных к общению с вашим фронтенд-приложением. Каждый вид ресурсов настраивается отдельно: картинки, JS-скрипты, CSS, шрифты, XHR-запросы и др. Вы можете сказать браузеру, что XHR-запросы можно слать только на конкретные домены, картинки брать только с определённых ресурсов и т.д.CSP можно прописать в заголовках ответа веб-сервера:
location / {
proxy_pass api;
set $CSP "default-src 'self';";
set $CSP "${CSP} img-src 'self'
You must be registered for see links
https://www.google-analytics.com;";set $CSP "${CSP} frame-ancestors 'self';";
set $CSP "${CSP} style-src 'self';";
set $CSP "${CSP} font-src 'self';";
set $CSP "${CSP} connect-src
You must be registered for see links
*.google-analytics.com https://mc.yandex.ru;";add_header Content-Security-Policy $CSP;
}
Примечание: этот заголовок прописывается на веб-сервере, который отдает статику фронтенда, а не на сервере с API.
Либо в тэге мета-заголовке HTML-страницы:
Кроме того, CSP может запретить изменение и добавление inline-стилей и скриптов. Даже если XSS пролезла на сайт, браузер не позволит ей обмениваться данными со злоумышленником и выполнять зловредные операции.
Где браузеру стоит хранить сессионные данные
У большинства одностраничных приложений есть необходимость хранить данные на стороне клиента, то есть в браузере. А многие атаки направлены на то, чтобы получить доступ к этим данным. При этом данные могут быть разного назначения и разной ценности. Хранить их тоже стоит по-разному.
Выделим 4 варианта хранилища, которые часто используют в современном мире:
- Cookie
- localStorage
- Session storage
- HttpOnly Cookie
Есть ещё IndexedDB — более сложный механизм хранения данных на стороне браузера, который нужен, в основном, для приложений с режимом офлайн. В этой статье рассматривать его не будем.
Во многих сервисах — интернет-банках, интернет-магазинах и любых других сайтах с личным кабинетом — есть закрытая часть API, куда доступ разрешён только авторизованным пользователям. После авторизации, например, по логину и паролю, браузеру необходимо где-то хранить сессионный ключ (токен) пользователя, чтобы делать дальнейшие запросы к серверу. Где же хранить такой токен?
Cookie
Традиционный способ хранения информации на стороне клиента. Управлять этим хранилищем может как JS фронтенда, так и сервер через заголовок Set-Cookie. Cookies привязываются браузером к домену сервера и, в случае Same Origin Policy, будут автоматически подставляться в заголовки запросов к этому домену. При кроссдоменном запросе браузер будет требовать разрешение на обмен учётными данными — credentials. Cookies уязвимы к CSRF-атаке. В современных браузерах могут иметь опцию samesite, чтобы защититься от этой атаки, но с ней перестанут работать для кроссдоменных запросов. Кроме того, Cookie подвержены XSS атаке. Имеют ограничения по объему данных (4кб) и по количеству cookies на один домен.
localStorage
localStorage привязывается к документу источника (связка домен-протокол-порт), то есть, к фронтенд-странице. Любая вкладка этого источника в браузере имеет к нему доступ. Кроме того, браузер не отправляет автоматически на сервер данные, хранящиеся в localStorage, а значит, сервер самостоятельно не может ни писать, ни читать данные, что делает localStorage защищенным от CSRF. Ещё один значительный плюс — объём хранимых данных значительно больше, чем у Cookie. Несмотря на ряд преимуществ, localStorage никак не защищён от XSS-атак, что делает его опасным хранилищем для сессионных данных.
Session storage
По своей сути очень похож на localStorage, за исключением того, что данные хранятся на уровне одной вкладки. Используется там, где требуется разделение логики приложения относительно вкладок. Например, когда надо поддерживать в каждой вкладке своё websocket-соединение. Использование этого хранилища — достаточно редкое явление.
HttpOnly Сookie
Выделяю этот способ отдельно от обычных Cookies, потому как у него есть одно решающее отличие: фронтенд-приложение не имеет доступа к Cookies с флагом httpOnly. Проще говоря, такие Cookies читать и писать может только сервер. Делает он это через заголовки cookie и set-cookie соответственно. Это важное отличие защищает их от XSS-атак. В сочетании со средствами защиты от CSRF, это хранилище весьма безопасно для сессионных данных.
Выбор хранилища для сессионного токена стоит делать на основе архитектуры вашего приложения, потенциальных уязвимостей и возможных способах защиты.
Защита кода фронтенда от XSS
XSS-атака опасна только тогда, когда есть возможность вставить вредоносный код во фронтенд-содержимое, JS, HTML или даже CSS. Поэтому, во-первых, нужно стараться устранить такие возможности, а во-вторых, даже если злоумышленник смог поместить свои скрипты пользователю, сделать так, чтобы эти скрипты не достигли цели. С этим нам поможет CSP, описанный выше. Тем не менее, его недостаточно. Нужно обезопасить код одностраничного приложения от попадания чужого кода. О чём нужно помнить для этого при фронтенд-разработке:
1) Не доверяйте на 100% сторонним библиотекам. Собирать фронтенд современными сборщиками и проверять используемые пакеты с помощью npm audit (yarn audit). Если используете CI/CD, добавлять npm audit --audit-level=moderate перед шагом сборки. Если npm audit выявил уязвимости с уровнем moderate или выше, значит необходимо обновить эти библиотеки. Если обновление не помогло, стоит подумать, использовать ли эти пакеты. Эта мера защитит вас от большинства уязвимостей в подключаемых библиотеках.
2) Не вставляйте в код «чистые» значения переменных, которые потенциально могут содержать пользовательские данные. «Опасные» символы нужно экранировать. Рекомендуется делать это так:
& --> &
< --> <
> --> >
" --> "
' --> '
/ --> /
Современные фронтенд-фреймворки сами прекрасно с этим справляются, если вы им не скажете обратного. Например, во Vue для вставки переменной в разметку следует использовать синтаксис mustache ({{value}}), а не атрибут v-html. Иногда возникают ситуации, когда всё же надо вставлять HTML из внешнего источника. Делать это позволительно только тогда, когда вы полностью этому источнику доверяете. Например, это сервисные сообщения, сгенерированные бэкендом. Но и в этом случае лучше подстраховаться и пропускать такие переменные через, например,
You must be registered for see links
.3) НИКОГДА не используйте в JS конструкцию eval(). Поверьте, должен быть другой способ решения вашей задачи.
4) Не храните важные данные в Сookie. Если нужно хранилище сессий — договоритесь с бэкэнд-разработчиками и используйте только HttpOnly Сookie.
5) Не объединяйте на одном домене сайты разного назначения. Например, у вас есть сайт сервиса на какой-нибудь популярной CMS, доступный по домену service.test. Личный кабинет сервиса на этом же домене, но с другим path, например service.test/account, который использует localStorage. Если злоумышленник найдёт и воспользуется уязвимостью CMS для внедрения XSS, он сможет завладеть localStorage клиента личного кабинета.
6) Дополнительно ознакомьтесь с
You must be registered for see links
.Немного практики с сессионными токенами и защита от CSRF
Сессионные токены — лакомый кусочек для злоумышленников. Это очень важные данные, сродни паре «логин-пароль». Можно использовать различные схемы защиты этих токенов, предлагаю рассмотреть одну из них.
Для начала сделаем такой токен доступа, у которого будет небольшой срок жизни. Тогда злоумышленник, предпринимающий атаку на похищение токена, может не успеть им воспользоваться даже в случае кражи. Однако если сделать период действия слишком коротким, обычному пользователю будет неудобно: перелогиниваться каждые 10 минут — то ещё удовольствие. Поэтому токены можно разделить на 2 вида: токен доступа (access_token) и токен обновления (refresh_token). С помощью access_token пользователь получает доступ к ресурсам API, а с помощью refresh_token пользователь запрашивает у API новый access_token. В чем же смысл? Злоумышленник ведь может украсть refresh_token и с помощью него получить действительный access_token. Для того, чтобы разобраться в этом, давайте рассмотрим небольшую модель с REST API ручками — действиями, которые вызываются через определенный URI API-сервера.
Пусть ручка получения POST /auth имеет следующий вид:
Запрос | Ответ |
---|