← назад к разделу

Фронтенд-разработчик пишет код, который выполняется на чужом компьютере, в чужом браузере и получает данные с сервера, которому нельзя доверять слепо. Пока всё работает, границы между этими тремя сторонами не видно. Она проявляется первой же ошибкой blocked by CORS policy, украденным токеном из localStorage или кнопкой, которую нажали с чужого сайта. Разберём, что делает браузер между вводом адреса и экраном, и какие правила он применяет к вашему коду и вашим данным.

От адреса до ответаспросят на собеседовании

Пользователь вводит адрес и нажимает Enter. Браузер смотрит кэш DNS, спрашивает систему и резолвер провайдера, получает IP-адрес сервера. Затем открывает TCP-соединение, поверх него договаривается о шифровании TLS, и только после этого отправляет HTTP-запрос: метод, путь, заголовки, иногда тело. Сервер отвечает статусом, заголовками и телом, обычно HTML. Браузер разбирает HTML, по пути находит ссылки на стили, скрипты и картинки и запрашивает их тем же способом, а HTTP/2 позволяет гнать десятки таких запросов по одному соединению.

Здесь важны две вещи для фронтенда. Первая: страница это не один запрос, а десятки, и порядок их загрузки определяет, когда пользователь увидит контент; об этом статья про критический путь рендеринга. Вторая: повторный визит не должен заново качать всё. Браузер кэширует ответы по заголовкам: Cache-Control: max-age говорит, сколько секунд ответ свежий, ETag даёт серверу возможность ответить 304 Not Modified без тела. Поэтому бандл собирают с отпечатком содержимого в имени файла и кэшируют на год, а index.html не кэшируют вовсе: новая версия меняет имена файлов, старые остаются в кэше безвредно.

Политика одного источника и CORSспросят на собеседовании

Источник, origin, это схема, домен и порт вместе: https://shop.example и https://api.shop.example разные источники. Браузер держит правило: скрипт со страницы одного источника не может читать ответы другого. Иначе любой сайт, который вы открыли, мог бы сделать запрос к вашему банку с вашими cookie и прочитать баланс.

CORS это способ сервера сказать браузеру «этому источнику читать можно». Сервер добавляет к ответу Access-Control-Allow-Origin с адресом страницы, и браузер пропускает ответ в код. Простые запросы, GET и POST с обычными заголовками, уходят сразу, а ответ фильтруется по этому заголовку. Для запросов с Content-Type: application/json, нестандартными заголовками или методами PUT и DELETE браузер сначала шлёт предварительный запрос OPTIONS, preflight, и ждёт разрешения. Отсюда три следствия, которые путают. Ошибка CORS это решение браузера, сервер запрос получил и даже мог его выполнить. Чинится CORS только на сервере, никакой заголовок на клиенте не поможет. И curl или Postman работают без CORS, потому что это не браузер.

Если запрос должен нести cookie на другой источник, нужны и credentials: "include" на клиенте, и Access-Control-Allow-Credentials: true на сервере, причём с конкретным источником, а не звёздочкой.

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

Cookie устроены иначе: их ставит сервер заголовком Set-Cookie или код, и браузер сам прикладывает их к каждому запросу на тот домен. У cookie есть атрибуты, которые и решают вопросы безопасности. HttpOnly прячет cookie от JavaScript, и украсть её через внедрённый скрипт нельзя. Secure разрешает отправку только по HTTPS. SameSite=Lax или Strict запрещает отправку с запросами, которые инициировал чужой сайт. Max-Age задаёт срок.

Отсюда ответ на вопрос «где хранить токен». Токен в localStorage читает любой скрипт на странице, включая внедрённый через уязвимость или через чужую библиотеку, и из него токен уезжает к атакующему. Токен в cookie с HttpOnly, Secure и SameSite недоступен скриптам вообще, а браузер приложит его сам. Поэтому сессию или refresh-токен кладут в такую cookie, а если короткоживущий access-токен всё же держат в памяти страницы, то именно в памяти, в переменной, а не в хранилище, и получают заново после перезагрузки.

XSS: чужой скрипт на вашей страницеспросят на собеседовании

Межсайтовый скриптинг это когда строка от пользователя попадает в страницу как разметка. Комментарий с текстом <img src=x onerror="...">, вставленный через innerHTML, выполнит код у каждого, кто откроет страницу, с правами этой страницы: прочитает localStorage, отправит запрос от имени пользователя, подменит форму входа.

Защита начинается с правила: данные вставляют как текст, а не как разметку. React экранирует всё, что рендерит в JSX, и уязвимость появляется только там, где это обходят руками: dangerouslySetInnerHTML, href со значением javascript:, вставка в <script> или в атрибуты событий. HTML от пользователя, если он действительно нужен, пропускают через санитайзер вроде DOMPurify. Второй слой это Content Security Policy: заголовок, который перечисляет, откуда странице можно загружать скрипты, и запрещает инлайновые; с ним внедрённый скрипт не выполнится, даже если прошёл в разметку. Третий слой это те самые HttpOnly-cookie, из-за которых украсть сессию через XSS нельзя.

CSRF: запрос от имени пользователя без его ведомаспросят на собеседовании

Подделка межсайтового запроса использует то, что браузер прикладывает cookie к любому запросу на домен. Пользователь вошёл в банк, потом открыл чужую страницу, и та отправила форму POST /transfer на банк: cookie ушли, сервер выполнил перевод. Скрипту с чужой страницы ответ читать нельзя, но ему и не нужно, действие уже сделано.

Защита двойная. SameSite=Lax на cookie, сегодня значение по умолчанию, не отправляет cookie с POST из чужого источника; Strict не отправляет и с переходов по ссылке. И CSRF-токен: сервер выдаёт странице случайное значение, форма или заголовок возвращают его, а чужая страница его не знает, потому что ответ сервера прочитать не может. Для API, где авторизация идёт заголовком Authorization, а не cookie, CSRF не страшен: чужая страница заголовок не подставит.

Когда запросов мало: WebSocket и SSEспросят на собеседовании

HTTP устроен как вопрос и ответ, и чат, курс валют или прогресс задачи в него ложатся плохо: страница вынуждена спрашивать сервер по таймеру. Два способа получать данные по инициативе сервера. Server-Sent Events это обычный HTTP-ответ, который не заканчивается, а сервер пишет в него события; в браузере это EventSource, который сам переподключается, но данные идут только от сервера к клиенту. WebSocket это отдельный двусторонний канал поверх того же TCP после рукопожатия, подходит для чата и игр, но переподключение и авторизацию делают сами, а балансировщики и прокси требуют настройки.

Правило выбора: уведомления и обновления с сервера, SSE; переписка в обе стороны с низкой задержкой, WebSocket; всё остальное, обычные запросы. Подробности про протоколы, масштабирование и подводные камни в статье про WebSocket и SSE.

Коротко

  • Путь страницы: DNS, TCP, TLS, запрос HTTP, разбор HTML и десятки запросов за ресурсами; Cache-Control и ETag управляют кэшем, бандл с отпечатком кэшируют на год, index.html нет.
  • Источник это схема, домен и порт; политика одного источника запрещает читать чужие ответы, CORS это разрешение сервера, preflight OPTIONS для непростых запросов; чинится только на сервере.
  • localStorage бессрочный и общий для вкладок, sessionStorage на вкладку, cookie уходят на сервер сами; HttpOnly, Secure и SameSite делают cookie местом для сессии и refresh-токена.
  • XSS: данные вставляют как текст, dangerouslySetInnerHTML только после санитайзера, CSP запрещает чужие и инлайновые скрипты, HttpOnly не даёт украсть сессию.
  • CSRF: SameSite на cookie и CSRF-токен; API с заголовком Authorization подделке не подвержено.
  • SSE для потока от сервера с автопереподключением, WebSocket для двусторонней связи, обычный HTTP для всего остального.

Что почитать дальше