Узнайте, как работают файлы cookies (куки) HTTP: простые практические примеры с использованием JavaScript и Python.
Что такое файлы cookies в веб-разработке?
Файлы куки — это крошечные фрагменты данных, которые серверная часть может хранить в браузерах пользователя. Отслеживание пользователей, персонализация и, самое главное, аутентификация — наиболее распространенные варианты использования файлов куки.
Они имеют большое количество проблем с конфиденциальностью. Поэтому на протяжении многих лет их использование строго регулируется.
В этой статье мы сосредоточимся в основном на технической стороне. Вы узнаете, как создавать, использовать и работать с файлами куки как во фронтенде, так и в бэкенде.
Содержание
- Что такое файлы cookies в веб-разработке?
- Настройка бэкенда
- Кто создает cookies?
- Как можно посмотреть файлы cookies
- Итак, у нас есть cookies. Что дальше?
- Срок давности куков. Атрибуты
Max-Age
иexpires
- Cookies, ограниченные атрибутом
Path
- Cookies, ограниченные атрибутом
Domain
- Перемещение cookies по запросам AJAX
- Cookies не всегда могут передаваться по запросам AJAX
- Работа с CORS
- Конкретный пример
- Cookies могут иметь своего рода секрет: атрибут
Secure
- Не трогайте мои куки: атрибут
HttpOnly
- Зловещий атрибут
SameSite
- Cookies и аутентификация
- Заключение
Настройка бэкенда
Примеры для бэкенда сделаны при помощи Python и его фреймворка Flask. Если вы хотите воспроизводить код из данной статьи, создайте новую виртуальную среду Python и установите Flask.
mkdir cookies && cd $_ python3 -m venv venv source venv/bin/activate pip install Flask
В каталоге проекта создайте файл flask_app.py
и используйте наши примеры для собственных экспериментов.
Кто создает cookies?
Перво-наперво надо выяснить, откуда берутся куки и кто их создает.
Хотя с помощью свойства document.cookie
можно создавать куки непосредственно в самом браузере, в большинстве случаев за их установку отвечает серверная часть (бэкенд).
Под этим мы подразумеваем, что куки создаются:
- реальным кодом приложения, которое работает на сервере и может быть написано на языках Python, JavaScript, PHP, Java и др.
- самим веб-сервером, отвечающим на запросы (Nginx, Apache)
Для этого бэкенд отправляет в ответ на запрос HTTP-заголовок с именем Set-Cookie
и со строкой, состоящей из пары ключ=значение и необязательных атрибутов:
Set-Cookie: myfirstcookie=somecookievalue
Когда и где создавать эти файлы куки, зависит от соответствующих требований.
Итак, куки это просто строки. Рассмотрим пример с Python и Flask. Сохраните следующий код в файл flask_app.py
, который вы создали в директории проекта.
from flask import Flask, make_response app = Flask(__name__) @app.route("/index/", methods=["GET"]) def index(): response = make_response("Here, take some cookie!") response.headers["Set-Cookie"] = "myfirstcookie=somecookievalue" return response
Затем запустите приложение:
FLASK_ENV=development FLASK_APP=flask_app.py flask run
Когда это приложение запущено и пользователь посещает страницу http://127.0.0.1:5000/index/
, бэкенд устанавливает заголовок ответа с именем Set-Cookie
и с соответствующей парой ключ=значение.
(127.0.0.1:5000 — адрес и порт, которые прослушиваются по умолчанию для Flask-приложений в разработке).
Заголовок Set-Cookie
имеет ключевое значение для понимания того, как создавать куки.
response.headers["Set-Cookie"] = "myfirstcookie=somecookievalue"
Справа от знака равенства вы видите собственно куки — "myfirstcookie=somecookievalue"
.
Большинство фреймворков имеют собственные служебные функции для программной установки файлов куки. Во Flask, например, эта функция называется set_cookie()
.
Под капотом такие функции в ответ на запрос просто устанавливают заголовок с Set-Cookie
.
Как можно посмотреть файлы cookies
Снова рассмотрим предыдущий пример с Flask. Когда вы посещаете http://127.0.0.1:5000/index/
, серверная часть устанавливает куки в браузере. Чтобы увидеть этот файл куки, вы можете вызвать document.cookie
из консоли браузера:
Или проверьте вкладку Storage
в инструментах разработчика. Нажмите на Cookies
, и вы должны увидеть файл куки:
Также можно использовать команду curl
в командной строке. Вы увидите, какие файлы куки были установлены бэкендом.
curl -I http://127.0.0.1:5000/index/
Чтобы сохранить куки в новый файл для дальнейшего использования, воспользуйтесь следующей командой:
curl -I http://127.0.0.1:5000/index/ --cookie-jar mycookies
Для того чтобы вывести куки в консоль, наберите вот эту команду:
curl -I http://127.0.0.1:5000/index/ --cookie-jar -
Обратите внимание, что файлы куки без атрибута HttpOnly
доступны в document.cookie
из JavaScript в браузере. А вот куки, помеченные как HttpOnly
, недоступны из JavaScript.
Чтобы пометить cookie как HttpOnly, нужно передать следующий атрибут:
Set-Cookie: myfirstcookie=somecookievalue; HttpOnly
Теперь куки по-прежнему будут отображаться на вкладке Cookie Storage
, но document.cookie
вернет пустую строку.
С этого момента для удобства мы будем создавать куки при помощи функции response.set_cookie() из фреймворка Flask.
Проверять файлы куки мы будем тремя способами:
- командой
curl
- при помощи инструментов разработчика (developer tools) браузера Firefox
- при помощи инструментов разработчика (developer tools) браузера Chrome.
Итак, у нас есть cookies, и что дальше?
Ваш браузер получил куки. И что теперь? Если у вас есть куки, браузер может отправлять их обратно на серверную часть.
Это может делаться с разными целями. Например, для отслеживания пользователей, персонализации и, самое главное, аутентификации.
При авторизации на сайте сервер может предоставить вам вот такой кук:
Set-Cookie: userid=sup3r4n0m-us3r-1d3nt1f13r
Чтобы правильно идентифицировать вас при каждом последующем запросе, бэкенд проверяет кук, поступающий из браузера в запросе.
Чтобы отправить такой кук, браузер добавляет в запрос заголовок Cookie
:
Cookie: userid=sup3r4n0m-us3r-1d3nt1f13r
Как, когда и почему браузер отправляет куки — это темы следующих разделов.
Куки имеют срок давности. Атрибуты Max-Age и expires
По умолчанию срок действия куков истекает, когда пользователь завершает сеанс, то есть когда он закрывает браузер. Чтобы сохранить куки, мы можем передать атрибуты expires
или Max-Age
:
Set-Cookie: myfirstcookie=somecookievalue; expires=Tue, 09 Jun 2020 15:46:52 GMT; Max-Age=1209600
Когда присутствуют оба атрибута, Max-Age
имеет приоритет над expires
.
Cookies, ограниченные атрибутом Path
Рассмотрим бэкенд, который устанавливает новый кук для своего фронтенда при посещении страницы http://127.0.0.1:5000/
. При двух других запросах вместо этого мы просто выводим в консоль куки самого запроса:
from flask import Flask, make_response, request app = Flask(__name__) @app.route("/", methods=["GET"]) def index(): response = make_response("Here, take some cookie!") response.set_cookie(key="id", value="3db4adj3d", path="/about/") return response @app.route("/about/", methods=["GET"]) def about(): print(request.cookies) return "Hello world!" @app.route("/contact/", methods=["GET"]) def contact(): print(request.cookies) return "Hello world!"
Запустим наше приложение:
FLASK_ENV=development FLASK_APP=flask_app.py flask run
В другом терминале, установив соединение с корневым маршрутом (root), мы увидим вот такой кук в Set-Cookie
:
curl -I http://127.0.0.1:5000/ --cookie-jar cookies HTTP/1.0 200 OK Content-Type: text/html; charset=utf-8 Content-Length: 23 Set-Cookie: id=3db4adj3d; Path=/about/ Server: Werkzeug/1.0.1 Python/3.8.3 Date: Wed, 27 May 2020 09:21:37 GMT
Обратите внимание, что у кука есть атрибут Path
:
Set-Cookie: id=3db4adj3d; Path=/about/
Теперь давайте посетим маршрут /about/
, отправив кук, который мы сохранили при первом посещении:
curl -I http://127.0.0.1:5000/about/ --cookie cookies
В терминале, где запущено приложение Flask, вы должны увидеть следующее:
ImmutableMultiDict([('id', '3db4adj3d')]) 127.0.0.1 - - [27/May/2020 11:27:55] "HEAD /about/ HTTP/1.1" 200 -
Как и ожидалось, кук возвращается на серверную часть. Теперь попробуйте посетить страницу /contact/
:
curl -I http://127.0.0.1:5000/contact/ --cookie cookies
На этот раз в терминале, где запущено приложение Flask, вы должны увидеть вот что:
ImmutableMultiDict([]) 127.0.0.1 - - [27/May/2020 11:29:00] "HEAD /contact/ HTTP/1.1" 200 -
Что все это означает? Куки ограничены своими путями. Кук с заданным атрибутом Path
не может быть отправлен на другой, несвязанный путь, даже если оба пути находятся в одном домене.
Это первый уровень разрешений для куков.
Если при создании кука атрибут Path задан не был, браузеры по умолчанию принимают его равным ‘/’.
Cookies, ограниченные атрибутом Domain
Значение атрибута Domain
кука определяет, должен ли браузер принимать его, и куда возвращается сам кук.
Давайте рассмотрим несколько примеров.
ПРИМЕЧАНИЕ: следующий URL-адрес находится на бесплатном сервере Heroku. Поэтому дайте ему немного времени, чтобы он набрал обороты и после этого откройте консоль браузера.
Несоответствующий хост (неправильный адрес хоста)
Рассмотрим следующий набор куков, установленных для https://serene-bastion-01422.herokuapp.com/get-wrong-domain-cookie/:
Set-Cookie: coookiename=wr0ng-d0m41n-c00k13; Domain=api.valentinog.com
Здесь кук исходит из serene-bastion-01422.herokuapp.com
, но в атрибуте Domain
значится api.valentinog.com
.
У браузеров нет другого выбора, кроме как отклонить данный кук. Chrome даже выдаст предупреждение (в отличии от Firefox):
Несоответствующий хост (поддомен)
Рассмотрим следующий кук, установленный https://serene-bastion-01422.herokuapp.com/get-wrong-subdomain-cookie/:
Set-Cookie: coookiename=wr0ng-subd0m41n-c00k13; Domain=secure-brushlands-44802.herokuapp.com
Здесь кук исходит из serene-bastion-01422.herokuapp.com
, но атрибут Domain
имеет значение secure-brushlands-44802.herokuapp.com
.
Они находятся в одном домене, но поддомены отличаются. И снова браузер отклоняет такой кук:
Соответствующий хост (весь домен)
Теперь рассмотрим следующий кук, установленный при посещении страницы https://www.valentinog.com/get-domain-cookie.html:
set-cookie: cookiename=d0m41n-c00k13; Domain=valentinog.com
Этот кук устанавливается на уровне веб-сервера при помощи Nginx add_header
:
add_header Set-Cookie "cookiename=d0m41n-c00k13; Domain=valentinog.com";
Здесь мы использовали Nginx, чтобы показать вам различные способы установки куков. Тот факт, что кук установлен сервером, а не кодом приложения, для браузера особой роли не играет. Важно то, из какого домена поступает кук.
Здесь браузер с радостью примет кук, потому что хост в Domain
включает в себя хост, с которого пришел сам кук.
Другими словами, valentinog.com
включает поддомен www.valentinog.com
.
Кроме того, кук возвращается при любых новых запросах к valentinog.com
, а также при любых запросах к поддоменам на valentinog.com
.
Вот запрос к поддомену с прикрепленным куком:
А вот запрос к другому поддомену с автоматически прикрепленным куком:
Куки и список общедоступных суффиксов
Теперь рассмотрим следующий кук, установленный https://serene-bastion-01422.herokuapp.com/get-domain-cookie/:
Set-Cookie: coookiename=d0m41n-c00k13; Domain=herokuapp.com
Здесь кук поступает с serene-bastion-01422.herokuapp.com
, а атрибут Domain
имеет значение herokuapp.com
. Что в такой ситуации нужно делать браузеру?
Вы можете подумать, что, так как serene-bastion-01422.herokuapp.com
входит в домен herokuapp.com, браузер должен принять этот кук.
Но вместо этого он отклонит данный кук, поскольку тот поступает из домена, включенного в список общедоступных суффиксов.
Public Suffix List поддерживается Мозиллой и используется всеми браузерами для ограничения установки куков от имени других доменов.
Полезные ресурсы:
Соответствующий хост (поддомен)
Теперь рассмотрим следующий кук, установленный https://serene-bastion-01422.herokuapp.com/get-subdomain-cookie/:
Set-Cookie: coookiename=subd0m41n-c00k13
Если при создании кука домен не указан, браузеры по умолчанию используют исходный хост в адресной строке. В этом случае наш код будет иметь следующий вид:
response.set_cookie(key="coookiename", value="subd0m41n-c00k13")
Когда кук попадает в хранилище браузера для куков, мы видим, что домен был указан:
Итак, у нас есть этот кук с сайта serene-bastion-01422.herokuapp.com
. Куда теперь следует его отправить?
Если вы посетите страницу https://serene-bastion-01422.herokuapp.com/, кук будет сопровождаться следующим запросом:
Но если вы просто зайдете на сайт herokuapp.com
, данный кук вообще не покинет ваш браузер:
(То, что herokuapp.com
перенаправляется на heroku.com
, не играет никакой роли).
Напомним, как браузер решает, что делать с куками. Здесь под хостом отправителя мы подразумеваем фактический URL-адрес, который вы посещаете.
Итак, браузер:
- Полностью отклоняет кук, если домен или поддомен в атрибуте Domain не соответствуют хосту отправителя.
- Отклоняет кук, если значение домена включено в список общедоступных суффиксов.
- Принимает кук, если домен или поддомен в атрибуте Domain совпадает с хостом отправителя.
Когда браузеры принимают куки и собираются сделать запрос, они говорят:
Отправьте мне кук, если хост запроса точно соответствует значению, которое я видел в атрибуте Domain. Отправьте мне кук, если хост запроса является субдоменом, точно соответствующим значению, которое я видел в атрибуте Domain. Отправьте мне кук, если хост запроса является субдоменом, например sub.example.dev, включенным в атрибут Domain, например example.dev. Не отправляйте мне кук, если хост запроса является основным доменом, например example.dev, а атрибут Domain был sub.example.dev.
Вывод: атрибут Domain
— это второй уровень разрешений для куков, наряду с атрибутом Path
.
Перемещение cookies по запросам AJAX
Куки могут перемещаться по запросам AJAX. Запросы AJAX — это асинхронные HTTP-запросы, сделанные с помощью JavaScript (XMLHttpRequest или Fetch) для получения и отправки данных на серверную часть.
Рассмотрим еще один пример с Flask. У нас есть шаблон, который загружает файл JavaScript. Вот приложение Flask:
from flask import Flask, make_response, render_template app = Flask(__name__) @app.route("/", methods=["GET"]) def index(): return render_template("index.html") @app.route("/get-cookie/", methods=["GET"]) def get_cookie(): response = make_response("Here, take some cookie!") response.set_cookie(key="id", value="3db4adj3d") return response
Вот шаблон из файла templates/index.html
:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <button>FETCH</button> </body> <script src="{{ url_for('static', filename='index.js') }}"></script> </html>
Вот JavaScript-код из файла static/index.js
:
const button = document.getElementsByTagName("button")[0]; button.addEventListener("click", function() { getACookie(); }); function getACookie() { fetch("/get-cookie/") .then(response => { // make sure to check response.ok in the real world! return response.text(); }) .then(text => console.log(text)); }
При посещении страницы http://127.0.0.1:5000/ мы видим кнопку. Нажимая на кнопку, мы делаем Fetch
запрос в /get-cookie/
, чтобы получить кук обратно. Как и ожидалось, кук попадает в хранилище куков нашего браузера.
Теперь давайте немного изменим наше приложение Flask:
from flask import Flask, make_response, request, render_template, jsonify app = Flask(__name__) @app.route("/", methods=["GET"]) def index(): return render_template("index.html") @app.route("/get-cookie/", methods=["GET"]) def get_cookie(): response = make_response("Here, take some cookie!") response.set_cookie(key="id", value="3db4adj3d") return response @app.route("/api/cities/", methods=["GET"]) def cities(): if request.cookies["id"] == "3db4adj3d": cities = [{"name": "Rome", "id": 1}, {"name": "Siena", "id": 2}] return jsonify(cities) return jsonify(msg="Ops!")
Кроме того, давайте немного подстроим наш код JavaScript, чтобы сделать еще один Fetch
-запрос после получения кука:
const button = document.getElementsByTagName("button")[0]; button.addEventListener("click", function() { getACookie().then(() => getData()); }); function getACookie() { return fetch("/get-cookie/").then(response => { // make sure to check response.ok in the real world! return Promise.resolve("All good, fetch the data"); }); } function getData() { fetch("/api/cities/") .then(response => { // make sure to check response.ok in the real world! return response.json(); }) .then(json => console.log(json)); }
При посещении страницы http://127.0.0.1:5000/ мы видим кнопку. Нажимая на кнопку, мы делаем Fetch
-запрос в /get-cookie/
, чтобы получить кук обратно. Как только приходит кук, мы делаем еще один Fetch-. запрос в /api/cities/
.
В консоли браузера вы должны увидеть массив городов. Кроме того, на вкладке Network инструментов разработчика вы должны увидеть заголовок с именем кука, передаваемого в бэкэнд по запросу AJAX.
Этот обмен куками между интерфейсом и серверной частью работает нормально, пока интерфейс находится в одном контексте с серверной частью: мы говорим, что они находятся в одном источнике.
Это потому, что по умолчанию Fetch отправляет учетные данные, то есть куки, только тогда, когда запрос попадает в тот же источник, из которого он запускается.
Здесь JavaScript обслуживается шаблоном Flask на странице http://127.0.0.1:5000/.
А теперь давайте посмотрим, что происходит при разных источниках.
Cookies не всегда могут передаваться по запросам AJAX
Рассмотрим другую ситуацию, когда серверная часть работает автономно. Итак, у вас запущено следующее приложение Flask:
FLASK_ENV=development FLASK_APP=flask_app.py flask run
Теперь в другой папке, вне приложения Flask, создайте файл index.html
:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <button>FETCH</button> </body> <script src="index.js"></script> </html>
Создайте в той же папке файл JavaScript с именем index.js
со следующим кодом:
const button = document.getElementsByTagName("button")[0]; button.addEventListener("click", function() { getACookie().then(() => getData()); }); function getACookie() { return fetch("http://localhost:5000/get-cookie/").then(response => { // make sure to check response.ok in the real world! return Promise.resolve("All good, fetch the data"); }); } function getData() { fetch("http://localhost:5000/api/cities/") .then(response => { // make sure to check response.ok in the real world! return response.json(); }) .then(json => console.log(json)); }
В этой же папке из терминала запустите команду npx serve
.
Эта команда дает вам локальный адрес / порт для подключения, например http://localhost: 42091/. Зайдите на страницу и попробуйте нажать кнопку с открытой консолью браузера (в инструментах разработчика). В консоли вы должны увидеть следующее:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:5000/get-cookie/. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing)
Это потому, что http://localhost: 5000/ не то же самое, что http://localhost: 42091/. Они имеют разные источники, отсюда и применение CORS.
Источник состоит из схемы, домена и номера порта. Это означает, что http://localhost: 5000/ отличается от http://localhost: 42091/.
Работа с CORS
CORS — это аббревиатура от Cross-Origin Resource Sharing — «Совместное использование ресурсов между разными источниками». Это способ для серверов контролировать доступ к ресурсам в данном источнике, когда код JavaScript, работающий в другом источнике, запрашивает эти ресурсы.
По умолчанию браузеры блокируют запросы AJAX к удаленным ресурсам, которые не находятся в одном источнике, если только сервер не предоставляет определенный HTTP-заголовок с именем Access-Control-Allow-Origin
.
Чтобы исправить предыдущую ошибку, нам нужно настроить CORS для Flask:
pip install flask-cors
Далее применим CORS к нашему коду:
from flask import Flask, make_response, request, render_template, jsonify from flask_cors import CORS app = Flask(__name__) CORS(app=app) @app.route("/", methods=["GET"]) def index(): return render_template("index.html") @app.route("/get-cookie/", methods=["GET"]) def get_cookie(): response = make_response("Here, take some cookie!") response.set_cookie(key="id", value="3db4adj3d") return response @app.route("/api/cities/", methods=["GET"]) def cities(): if request.cookies["id"] == "3db4adj3d": cities = [{"name": "Rome", "id": 1}, {"name": "Siena", "id": 2}] return jsonify(cities) return jsonify(msg="Ops!")
Теперь попробуйте еще раз нажать кнопку с открытой консолью браузера. В консоли вы должны увидеть следующее:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:5000/api/cities/. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing)
Несмотря на то, что мы получили ту же ошибку, на этот раз виноват второй маршрут.
К запросу не прикреплен кук с именем «id», поэтому Flask аварийно завершает работу и не устанавливает Access-Control-Allow-Origin
.
В этом можно убедиться, посмотрев запрос во вкладке Network (в инструментах разработчика). Такие куки не отправляются:
Чтобы включить cookies в Fetch-запросы из разных источников, мы должны предоставить флаг учетных данных.
Без этого флага Fetch просто игнорирует куки. Чтобы исправить наш пример, изменим код следующим образом:
const button = document.getElementsByTagName("button")[0]; button.addEventListener("click", function() { getACookie().then(() => getData()); }); function getACookie() { return fetch("http://localhost:5000/get-cookie/", { credentials: "include" }).then(response => { // make sure to check response.ok in the real world! return Promise.resolve("All good, fetch the data"); }); } function getData() { fetch("http://localhost:5000/api/cities/", { credentials: "include" }) .then(response => { // make sure to check response.ok in the real world! return response.json(); }) .then(json => console.log(json)); }
credentials: "include"
должен присутствовать в первом Fetch-запросе, чтобы сохранить кук в хранилище.
fetch("http://localhost:5000/get-cookie/", { credentials: "include" })
Он также должен присутствовать во втором запросе, чтобы разрешить передачу куков обратно в серверную часть:
fetch("http://localhost:5000/api/cities/", { credentials: "include" })
Попробуйте еще раз, и вы увидите, что нам нужно исправить еще одну ошибку на сервере:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:5000/get-cookie/. (Reason: expected ‘true’ in CORS header ‘Access-Control-Allow-Credentials’).
Чтобы разрешить передачу куков в запросах CORS, серверная часть также должна предоставить заголовок Access-Control-Allow-Credentials
. Это легко поправить.
CORS(app=app, supports_credentials=True)
Теперь вы должны увидеть ожидаемый массив городов в консоли браузера.
Выводы: чтобы cookies передавались по запросам AJAX между разными источниками, вы должны обеспечить следующее:
credentials: "include"
во фронтенде для Fetch-запросаAccess-Control-Allow-Credentials
иAccess-Control-Allow-Origin
в бэкенде.
Куки могут передаваться по запросам AJAX, но они должны соответствовать доменным правилам, которые мы здесь описали.
Конкретный пример
В нашем предыдущем примере использовался localhost, чтобы все было просто и воспроизводимо на вашем локальном компьютере.
Чтобы представить обмен куками по запросам AJAX в реальном мире, вы можете наметить себе следующий сценарий:
- Пользователь заходит на сайт https://www.a-example.dev.
- Он нажимает кнопку или выполняет какое-либо действие, которое запускает Fetch-запрос на https://api.b-example.dev.
- https://api.b-example.dev устанавливает кук с атрибутом
Domain=api.b-example.dev
. - При последующих Fetch запросах на https://api.b-example.dev кук отправляется обратно.
Куки могут иметь своего рода секрет: атрибут Secure
Но этот секрет не такой уж и секретный, по большому счету.
Атрибут Secure
для куков гарантирует, что куки никогда не будут приняты через HTTP
. То есть браузер отклоняет куки с данным атрибутом, если соединение не происходит через HTTPS
.
Чтобы пометить кук как безопасный, передайте в него соответствующий атрибут:
Set-Cookie: "id=3db4adj3d; Secure"
В коде сервера Flask:
response.set_cookie(key="id", value="3db4adj3d", secure=True)
Если вы хотите попробовать это в реальной среде, выполните следующую команду в консоли и обратите внимание, как здесь curl
не сохраняет куки через HTTP:
curl -I http://serene-bastion-01422.herokuapp.com/get-secure-cookie/ --cookie-jar -
Примечание: это будет работать только в версии curl >=7.64.0, который реализует rfc6265bis. Более старые версии curl реализуют RCF6265.
А через HTTPS куки появляются в хранилище:
curl -I https://serene-bastion-01422.herokuapp.com/get-secure-cookie/ --cookie-jar -
Вот как оно выглядит:
serene-bastion-01422.herokuapp.com FALSE / TRUE 0 id 3db4adj3d
Чтобы проверить куки в браузере, посетите обе версии указанного выше URL-адреса и проверьте хранилище куков в инструментах разработчика.
Не обманывайтесь самим словом Secure
(безопасный). Куки придут к вам по безопасному соединению, но после того как они окажутся в вашем браузере, никакой защиты у них не будет.
По этой причине безопасный кук, как и любой другой, не предназначен для передачи конфиденциальных данных, даже если название предполагает обратное.
Не трогайте мои куки: атрибут HttpOnly
Атрибут HttpOnly
для кука гарантирует, что этот самый кук недоступен для кода JavaScript. Это самая главная форма защиты против XSS атак.
Однако он отправляется при каждом последующем HTTP-запросе с учетом всех разрешений, установленных атрибутами Domain
и Path
.
Чтобы пометить кук как HttpOnly
, установите данный атрибут следующим образом:
Set-Cookie: "id=3db4adj3d; HttpOnly"
В сервере Flask:
response.set_cookie(key="id", value="3db4adj3d", httponly=True)
К куку, помеченному как HttpOnly
, нельзя получить доступ из JavaScript. При проверке в консоли document.cookie
будет возвращать пустую строку.
Однако, если установить credentials
обратно в состояние include
, то при помощи Fetch-запроса можно будет опять отправлять и получать куки с атрибутом HttpOnly
. Разумеется, при наличии разрешений от атрибутов Path
и Domain
.
fetch(/* url */, { credentials: "include" })
Когда же лучше использовать атрибут HttpOnly
? Всегда, когда это возможно. Куки всегда должны быть HttpOnly
, за исключением случаев, когда есть особые требования для их отображения во время выполнения JavaScript.
Зловещий атрибут SameSite
Собственные и сторонние куки
Рассмотрим кук, полученный при посещении страницы https://serene-bastion-01422.herokuapp.com/get-cookie/:
Set-Cookie: simplecookiename=c00l-c00k13; Path=/
Мы называем этот вид куков собственными. То есть, если я посещаю этот URL-адрес в браузере, и если я посещаю тот же URL-адрес или другую страницу на этом сайте (при условии, что атрибут Path
установлен в значение /
), то браузер отправляет куки обратно на сайт. Обычные куки, одним словом.
Теперь рассмотрим другую веб-страницу по адресу https://serene-bastion-01422.herokuapp.com/get-frog/. Эта страница также устанавливает куки и, кроме того, загружает изображение со стороннего ресурса, размещенное по адресу https://www.valentinog.com/cookie-frog.jpg.
Этот сторонний ресурс, в свою очередь, сам устанавливает свои куки. Вы можете увидеть этот сценарий на скрине ниже:
Примечание: если вы используете Chrome 85, вы не увидите этот кук. Начиная с этой версии Chrome отклоняет его. Мы называем этот вид куков сторонними. Другой пример стороннего кука:
- Пользователь посещает страницу https://www.a-example.dev.
- Он нажимает кнопку или выполняет какое-либо действие, которое запускает Fetch-запрос на странице https://api.b-example.dev
- Теперь на странице https://www.a-example.dev хранится сторонний кук с https://api.b-example.dev.
Работа с атрибутом Samesite
На момент написания данной статьи сторонние куки вызывают всплывающее окно с предупреждением в консоли Chrome:
«Файл cookie, связанный с межсайтовым ресурсом http://www.valentinog.com/, был установлен без атрибута SameSite
. Будущая версия Chrome будет доставлять файлы cookie с межсайтовыми запросами только в том случае, если они установлены с помощью SameSite=None
и Secure
.»
Браузер пытается нам сказать, что сторонние куки должны иметь новый атрибут SameSite
. Но почему?
Атрибут SameSite
— это новая функция, направленная на повышение безопасности куков. Она предназначена, чтобы предотвратить подделку межсайтовых запросов и избежать утечек конфиденциальности.
Атрибуту SameSite
может быть присвоено одно из трех значений:
- Strict
- Lax
- None
Если мы являемся службой, предоставляющей встраиваемые виджеты (iframe), или нам необходимо размещать куки на удаленных веб-сайтах (по уважительной причине, а не для дикого отслеживания), эти куки должны быть помечены как SameSite=None
и Secure
:
Set-Cookie: frogcookie=fr0g-c00k13; SameSite=None; Secure
В противном случае браузер отклонит сторонний кук. Вот что браузеры собираются делать в ближайшем будущем:
«Файл cookie, связанный с межсайтовым ресурсом http://www.valentinog.com/, был установлен без атрибута SameSite
. Он был заблокирован, так как Chrome теперь доставляет файлы cookie только с межсайтовыми запросами, если для них установлено значение SameSite=None
и Secure
.»
Другими словами SameSite=None; Secure
заставит сторонние куки работать так, как они работают сегодня, с той лишь разницей, что они должны передаваться только через HTTPS.
Настроенный таким образом кук отправляется вместе с каждым запросом, если домен и путь совпадают. Это нормальное поведение.
Стоит отметить, что атрибут SameSite
относится не только к сторонним кукам.
По умолчанию браузеры будут применять SameSite=Lax
для всех куков, как собственных, так и сторонних, если данный атрибут отсутствует. Вот какая политику у браузера Firefox Nightly относительно собственных куков:
«Кук get_frog_simplecookiename
имеет атрибут SameSite
, установленный в значение lax
, потому что в нем отсутствовал атрибут SameSite
, а SameSite=lax
является значением по умолчанию для этого атрибута.»
Сторонние куки с атрибутом SameSite=Strict
будут полностью отклоняться браузером.
Напомним, вот поведение браузера для разных значений SameSite
:
Значение | Входящий кук | Выходящий кук |
Strict | Отклонить | — |
Lax | Принять | Отправить безопасным методом HTTP |
None + Secure | Принять | Отправить |
Чтобы узнать больше о SameSite
и подробно разобраться во всех вариантах использования этого атрибута, почитайте эти отличные ресурсы:
- Prepare for SameSite Cookie Updates
- SameSite cookies explained
- SameSite cookie recipes
- Tough Cookies
- Cross-Site Request Forgery is dead!
- CSRF is (really) dead
Куки и аутентификация
Аутентификация — одна из самых сложных задач в веб-разработке. Похоже, что вокруг этой темы так много путаницы, поскольку аутентификация на основе токенов с JWT, видимо, заменяет «старые» твердые шаблоны, такие как аутентификация на основе сессий.
Посмотрим, какую роль здесь играют куки.
Аутентификация на основе сессий
Аутентификация — один из наиболее распространенных случаев использования куков.
Когда вы посещаете сайт, запрашивающий аутентификацию, то при отправке учетных данных (например, через форму) бэкенд отправляет фронтенду заголовок Set-Cookie
.
Типичный кук сессии выглядит следующим образом:
Set-Cookie: sessionid=sty1z3kz11mpqxjv648mqwlx4ginpt6c; expires=Tue, 09 Jun 2020 15:46:52 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax
В этом заголовке Set-Cookie
сервер может включать кук с именем session
, session id
или аналогичный.
Это единственный идентификатор, который браузер может видеть в открытом виде. Каждый раз, когда аутентифицированный пользователь запрашивает новую страницу в серверной части, браузер отправляет обратно кук сеанса.
На этом этапе, чтобы правильно идентифицировать пользователя, серверная часть связывает идентификатор сессии с самим сеансом, который находится в хранилище за кулисами.
Аутентификация на основе сессий представляет из себя отслеживание состояний, поскольку серверная часть должна отслеживать сессии для каждого пользователя. Хранилищем для этих сессий может быть:
- база данных
- хранилище ключей / значений, такое как Redis
- файловая система.
Из этих трех хранилищ сессий предпочтение следует отдавать Redis (или ему подобным).
Обратите внимание, что аутентификация на основе сессий не имеет ничего общего с хранилищем сессий браузера.
Она так называется (аутентификацией на основе сессий) только потому, что соответствующие данные для идентификации пользователя находятся в хранилище сессий бэкенда, что не то же самое, что хранилище сессий браузера.
Когда использовать аутентификацию на основе сессий?
Используйте ее всегда, когда это возможно. Аутентификация на основе сессий — одна из самых простых, безопасных и понятных форм аутентификации для сайтов. Она доступна по умолчанию во всех самых популярных веб-фреймворках, например в Django.
Но ее природа, связанная с отслеживанием состояния, также является и ее основным недостатком, особенно когда сайт обслуживается балансировщиком нагрузки. В этом случае могут помочь такие методы, как закрепление сессий или хранение сессий в централизованном хранилище Redis.
Замечание по поводу JWT
JWT (сокращение от JSON Web Tokens) — это механизм аутентификации, набирающий популярность в последние годы.
JWT хорошо подходит для одностраничных и мобильных приложений, но также создает и новый набор проблем. Типичный процесс для внешнего приложения, желающего пройти аутентификацию по API, следующий:
- Фронтенд отправляет учетные данные бэкенду.
- Бэкенд проверяет учетные данные и отправляет обратно токен.
- Фронтенд отправляет токен при каждом последующем запросе.
Главный вопрос, который возникает при таком подходе: где нам хранить этот токен во фронтенде, чтобы пользователь оставался авторизованным?
Самым естественным поступком для того, кто пишет на JavaScript, является сохранение токена в localStorage
. Это плохо по многим причинам.
localStorage
легко доступен из кода JavaScript и является легкой мишенью для XSS-атак.
Чтобы решить эту проблему, большинство разработчиков прибегают к сохранению токена JWT в куке, полагая, что HttpOnly
и Secure
могут защитить кук, по крайней мере, от XSS-атак.
Новый атрибут SameSite
, установленный в состояние SameSite=Strict
, также защитит ваш «кукинизированный» JWT от атак CSRF (Межсайтовая подделка запроса). Но это также полностью аннулирует вариант использования JWT во многих случаях, потому что SameSite = Strict
не отправляет куки по запросам из разных источников!
Как насчет того, чтобы установить атрибут в состояние SameSite=Lax
? Этот режим позволяет отправлять куки с помощью безопасных методов HTTP, а именно GET
, HEAD
, OPTIONS
и TRACE
. Запросы POST
ни в коем случае не будут передавать куки.
На самом деле хранение токена JWT в куке или в localStorage
— плохая идея.
Если вы действительно хотите использовать JWT вместо аутентификации на основе сессий, вы можете использовать JWT с обновляемыми токенами, чтобы пользователь все время оставался в системе.
Заключение
HTTP-куки существуют с 1994 года. Они просто повсюду.
Куки представляют собой простые текстовые строки, но их можно точно настроить для различных уровней допуска с помощью атрибутов Domain
и Path
. Их можно передавать только по HTTPS с помощью атрибута Secure
и скрыть от JavaScript при помощи атрибута HttpOnly
.
Кук может использоваться для персонализации взаимодействия с пользователем, аутентификации пользователей или для скрытых целей, таких как отслеживание.
Но при любом предполагаемом использовании куки могут открывать пользователей для атак и уязвимостей.
Так что же может сделать куки безопасными? Это просто невозможно. Мы можем считать их относительно безопасными, если они:
- передаются только через HTTPS, имеют атрибут
Secure
- имеют атрибут
HttpOnly
, если это возможно - имеют правильно сконфигурированный атрибут
SameSite
- не содержат важных данных.
Спасибо за внимание!
Перевод статьи «A practical, Complete Tutorial on HTTP cookies»