Команде разработчиков, создающей одностраничное приложение (SPA), рано или поздно придётся столкнуться с ограничениями браузерной безопасности. С одной стороны, нужно сделать так, чтобы фронтенд-сторона могла беспрепятственно общаться с бэкенд API-сервером, а с другой — защитить такое общение от злоумышленников. Сложности начинаются, когда фронтенд и бэкенд находятся на разных доменах, так как на такое взаимодействие браузер накладывает более строгие правила.
В клиентском HTML-JS приложении браузер выполняет важную роль «инспектора» внешних запросов и содержит в арсенале мощные инструменты. Наша задача — установить правила, по которым он будет применять эти инструменты к нашему приложению.
Я — разработчик в хостинг-провайдере FirstVDS. При создании SPA для одного из наших проектов я искал решения и применял их на практике, чтобы подружить фронтенд с API и обезопасить их общение. В этой статье я собрал свои мысли и опыт воедино, чтобы поделиться с вами.
От чего надо защищаться?
Потенциально веб-приложение находится под угрозой множества атак. В статье я не буду затрагивать угрозы, направленные только на бэкенд (SQL-инъекции, брутфорс, DDoS и прочие). Рассмотрим атаки, направленные на уязвимости в архитектуре фронтенда, которые реализуются через действия введённого в заблуждение пользователя в браузере. Особенно стоит выделить два больших класса атак: XSS и CSRF (XSRF), породивших за пару десятилетий множество подвидов.
XSS — межсайтовый скриптинг. Вид атак, внедряющих вредоносный код в страницу, которую просматривает пользователь. Такой код может быть внедрён в JS, HTML или даже CSS содержимое. Цели могут быть самые разные — от показа рекламных баннеров до кражи конфиденциальной информации. Внедрение кода становится возможным через провокацию ничего не подозревающего пользователя к вредоносным действиям или через уязвимости самого приложения. Так, самая распространённая XSS-атака основана на отражённой уязвимости. Пользователю, чаще по email, отправляется подготовленная ссылка с тэгом вредоносного скрипта в аргументе. Если сайт, на который она ведет, допускает вывод этого аргумента на странице, то браузер запустит скрипт, и на сайте приложения появится рекламный баннер конкурента. Другие примеры XSS-атак можно посмотреть в статье.
CSRF (XSRF) — межсайтовая подделка запроса. Чаще всего использует cookie другого хоста. Например, пользователю в социальной сети приходит сообщение, что он выиграл 10000000 рублей, чтобы их получить, надо только перейти по ссылке. Затем делается POST-запрос в сервис, где пользователь авторизован, в нашем примере — социальная сеть. В результате запрос выполняется с сессионными cookies пользователя и при отсутствии мер защиты такой запрос достигает цели — на странице жертвы начинают появляться посты с сомнительным содержимым. Как еще злоумышленники реализуют эти атаки, можно посмотреть в статье про виды CSRF-атак.
Конечно, список атак намного шире и постоянно пополняется. Хорошее описание для них есть на сайте проекта обеспечения безопасности веб-приложений OWASP.
Чтобы обезопасить пользователей и обезоружить злоумышленников, браузер берёт значительную часть работы на себя. Как правило, браузерные политики безопасности управляются заголовками со стороны-веб серверов. Одни заголовки нужны на сервере 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;
}
и не вспоминать про атаки типа Clickjacking. Если же вам нужна функция открытия в iFrame, то следует изучить этот вопрос подробнее. Функциональность заголовка X-Frame-Options была реализована в заголовке Content Security Policy, о котором поговорим ниже.
X-Content-Type-Options
Заголовок, призванный бороться с атаками типа MIME Sniffing. Атака несёт угрозу, когда ваше приложение позволяет загружать файлы на сервер с последующим получением к ним доступа по ссылке. Злоумышленник может подложить 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
При первом посещении сайта клиентом веб-сервер может сообщить браузеру, что открываемый ресурс должен загружаться только в рамках механизма HSTS, то есть только через https. Дальнейшие действия пользователя, даже введённого в заблуждение злоумышленником, который пытается перенаправить трафик на другой ресурс или протокол, например, через недобросовестный Wi-Fi, будут пресекаться на месте. До следующего обновления рекомендованное время действия — 1 год.
location / {
add_header Strict-Transport-Security "max-age=31536000";
}
Content Security Policy
Ещё один заголовок, о котором стоит рассказать отдельно, — Content Security Policy. Это очень мощный инструмент, который соединил в себе другие заголовки безопасности, такие как x-xss-protection или x-frame-options. С его помощью можно «попросить» браузер ограничить список ресурсов, допущенных к общению с вашим фронтенд-приложением. Каждый вид ресурсов настраивается отдельно: картинки, JS-скрипты, CSS, шрифты, XHR-запросы и др. Вы можете сказать браузеру, что XHR-запросы можно слать только на конкретные домены, картинки брать только с определённых ресурсов и т.д.
CSP можно прописать в заголовках ответа веб-сервера:
location / {
proxy_pass api;
set $CSP "default-src 'self';";
set $CSP "${CSP} img-src 'self' https://mc.yandex.ru 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 https://api.service.test *.google-analytics.com https://mc.yandex.ru;";
add_header Content-Security-Policy $CSP;
}
Примечание: этот заголовок прописывается на веб-сервере, который отдает статику фронтенда, а не на сервере с API.
Либо в тэге мета-заголовке HTML-страницы:
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
connect-src https://service.test *.google-analytics.com https://mc.yandex.ru;
script-src 'self' *.google-analytics.com *.googletagmanager.com https://mc.yandex.ru;
img-src 'self' https://mc.yandex.ru https://www.google-analytics.com;
frame-ancestors 'self';
style-src 'self';
font-src 'self'; ">
Кроме того, 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 из внешнего источника. Делать это позволительно только тогда, когда вы полностью этому источнику доверяете. Например, это сервисные сообщения, сгенерированные бэкендом. Но и в этом случае лучше подстраховаться и пропускать такие переменные через, например, HtmlSanitizer.
3) НИКОГДА не используйте в JS конструкцию eval()
. Поверьте, должен быть другой способ решения вашей задачи.
4) Не храните важные данные в Сookie. Если нужно хранилище сессий — договоритесь с бэкэнд-разработчиками и используйте только HttpOnly Сookie.
5) Не объединяйте на одном домене сайты разного назначения. Например, у вас есть сайт сервиса на какой-нибудь популярной CMS, доступный по домену service.test. Личный кабинет сервиса на этом же домене, но с другим path, например service.test/account, который использует localStorage. Если злоумышленник найдёт и воспользуется уязвимостью CMS для внедрения XSS, он сможет завладеть localStorage клиента личного кабинета.
6) Дополнительно ознакомьтесь с рекомендациями OWASP.
Немного практики с сессионными токенами и защита от CSRF
Сессионные токены — лакомый кусочек для злоумышленников. Это очень важные данные, сродни паре «логин-пароль». Можно использовать различные схемы защиты этих токенов, предлагаю рассмотреть одну из них.
Для начала сделаем такой токен доступа, у которого будет небольшой срок жизни. Тогда злоумышленник, предпринимающий атаку на похищение токена, может не успеть им воспользоваться даже в случае кражи. Однако если сделать период действия слишком коротким, обычному пользователю будет неудобно: перелогиниваться каждые 10 минут — то ещё удовольствие. Поэтому токены можно разделить на 2 вида: токен доступа (access_token) и токен обновления (refresh_token). С помощью access_token пользователь получает доступ к ресурсам API, а с помощью refresh_token пользователь запрашивает у API новый access_token. В чем же смысл? Злоумышленник ведь может украсть refresh_token и с помощью него получить действительный access_token. Для того, чтобы разобраться в этом, давайте рассмотрим небольшую модель с REST API.
Пусть эндпоинт аутентификации POST /auth имеет следующий вид:
Запрос | Ответ | |
---|---|---|
Body | {"username": "vasya","password": "vasya_hard_password"} |
{"access_token": "long_access_token"} |
Пользователь вводит свой логин и пароль, в теле ответа получает access_token. Фронтенд-приложение этот токен запоминает и далее может делать авторизованные xhr-запросы, например GET /user
Запрос | Ответ | |
---|---|---|
Body | {"username": "vasya","password": "vasya_hard_password"} |
{"access_token": "long_access_token"} |
Headers | Authorization: Baerer long_access_token |
Время жизни access_token, предположим, 10 минут, значит, через 10 минут пользователю придется перелогиниться для продолжения работы в приложении. Чтобы пользователя «не выбрасывало», нужен refresh_token, время жизни которого существенно выше, например, сутки. Тут встаёт вопрос, где же хранить эти токены со стороны браузерного клиента, ведь очевидно, что хранить их вместе не стоит. Рассматривать будем только 2 вида хранилища: localStorage, способное держать информацию в рамках одного источника одностраничного приложения, и httpOnly Cookie, привязанное к конкретному домену бэкенда. Session storage привязан к конкретной вкладке, поэтому не подходит, а обычные Cookie слишком небезопасны.
Важно понимать, что refresh token нужен только пользователям веб-страницы, а пользователи, обращающиеся к API со своих серверов, могут сами обновлять access_token при помощи логина и пароля.
Приложение на другом сервере, обращающееся к нашему API, не использует Cookie для общения. Ему необходимо добавлять access_token в заголовок запроса для подтверждения авторизации. Фронтенду тоже нужен доступ к access_token для добавления этого заголовка. Поэтому использование localStorage в качестве хранилища access_token — неплохой путь с учётом небольшого времени жизни и принятых мер по защите от XSS. А в качестве хранилища для refresh_token стоит использовать httpOnly cookie. Тогда в бэкенде нужно сделать так, чтобы на запрос авторизации POST /token/auth он формировал ответ с соответствующим заголовком Set-cookie.
Запрос | Ответ | |
---|---|---|
Body | {"username": "vasya","password": "vasya_hard_password"} |
{"access_token": "long_access_token"} |
Headers | Set-cookie: refresh_token=long_refresh_token; Domain=api.service.test; Expires=Date; HttpOnly; Secure; |
Мы помним, что любые cookies уязвимы к CSRF-атакам. Необходимо защитить refresh_token от CSRF атак дополнительно CSRF-токеном. Что? Ещё один токен? А его где хранить? Дополнительный токен не нужен, с задачей хорошо справится access_token. Используем его для проверки, что запрос POST /refresh выполнен санкционировано от авторизованного пользователя.
В эндпоинте POST /token/refresh используем для проверки сразу 2 токена: старый access_token взятый из localStorage и refresh_token взятый из httpOnly cookie при помощи заголовка cookie
Запрос | Ответ | |
---|---|---|
Body | {"access_token": "long_access_token"} |
|
Headers | X-CSRF-Token: long_access_token; cookie: refresh_token=long_refresh_token |
Со стороны бэкенда проверка access_token из заголовка X-CSRF-Token должна пропускать случай, если этот токен уже просрочен, но не так давно (~ время жизни refresh_token).
Важно знать, что если мы работаем в рамках политики CORS, то при совершении XHR-запроса с участием cookie нужно проводить такой запрос со специальным флагом withCredentials: true для XMLHttpRequest и credentials: 'include' для fetch. Без этого флага браузер запретит использовать cookie, в том числе и httpOnly, серверу с доменом, отличным от домена источника.
Таким образом, храня access_token и refresh_token в разных хранилищах, используя доступные средства защиты от XSS и CSRF-атак, мы сделаем наше приложение безопаснее.
API для нескольких экземпляров фронтенда
При разработке сервиса может возникнуть необходимость обращаться к одному API с разных источников фронтенда. Например, у разных интернет-магазинов или сред для тестирования фронтенда один бэкенд. Казалось, бы какие сложности? Добавляем в Access-Control-Allow-Origin несколько доменов и дело в шляпе. Но это путь неверный. Во-первых, контроль конфигов безопасности становится сложнее и запутаннее. Во-вторых, браузер не позволит использовать Сookie даже с флагом credentials, если в Access-Control-Allow-Origin будет более, чем один источник.
Хорошо, сделаем виртуальные хосты в Nginx, повесим их на разные порты и всё. Но и тут возникнет сложность. Сookie должны быть привязаны к конкретному домену, а порт они во внимание не берут. Поэтому можно столкнуться с ситуацией, когда одна cookie будет доступна с разных источников, что может привести к весьма неожиданным результатам.
Лучший выход — для каждого экземпляра фронтенда выделять свой домен API и создавать для него виртуальный хост Nginx со всеми необходимыми заголовками безопасности:
upstream api {
server unix:/opt/api/server.sock;
}
server {
listen 443;
server_name api1.service.test;
location / {
proxy_pass api;
add_header 'Access-Control-Allow-Origin' 'https://frontend1.service.test';
}
}
server {
listen 443;
server_name api2.service.test;
location / {
proxy_pass api;
add_header 'Access-Control-Allow-Origin' 'https://frontend2.service.test';
}
}
Cоответственно настроить конфиг фронтенд веб-сервера CSP политиками:
server {
listen 443;
server_name frontend1.service.test;
root "/var/www/frontend";
index index.html;
location / {
set $CSP "default-src 'self';";
set $CSP "...";
set $CSP "${CSP} connect-src https://api1.service.test ...";
add_header Content-Security-Policy $CSP;
}
}
server {
listen 443;
server_name frontend2.service.test;
root "/var/www/frontend";
index index.html;
location / {
set $CSP "default-src 'self';";
set $CSP "...";
set $CSP "${CSP} connect-src https://api2.service.test ...";
add_header Content-Security-Policy $CSP;
}
}
Пора заканчивать
Все ограничения, которые накладывает браузер, — результат десятилетий изучения атак на веб-приложения. Поэтому фронтенд-разработчику обязательно нужно понимать, как они работают. При проектировании приложения, основываясь на архитектуре и специфике данных, нужно сформировать у себя некоторые правила «гигиены» информационной защиты, придерживаться их и постоянно обновлять чтением литературы.
В завершение приведу слова своего преподавателя по защите информации: «Невозможно создать 100%-защиту. Наша задача — сделать так, чтобы взлом информации обходился дороже, чем сама информация».