Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 16 из 25 ~18 мин +50 XP

CORS, CSRF и безопасность: middleware.CORS, csrf-токены и заголовки Secure

echo cors — тема, которая назрела сама: вчера мы выпустили [JWT-пару](/courses/echo/lessons/15-jwt/), и access-токен теперь едет в заголовке Authorization — а это ровно тот заголовок, из-за которого браузер устраивает preflight, когда API зовут с чужого домена. Браузер не доверяет никому: Same-Origin Policy запрещает странице с одного источника читать ответы другого, и CORS — это управляемая уступка, набор заголовков, которыми сервер говорит «этому источнику можно». В echo за неё отвечает middleware.CORS из каталога [встроенных слоёв](/courses/echo/lessons/07-vstroennye-middleware/) — седьмой урок показал его одной строкой таблицы, сегодня разберём cors.go v4.15.0 целиком: дефолты, подстановочные шаблоны, preflight на OPTIONS и опасную комбинацию AllowCredentials со звёздочкой. Вторая половина урока — оборотная сторона: CSRF, когда сайт сам подставляет браузер против вас, csrf-токены middleware.CSRF с кукой _csrf и заголовком X-CSRF-Token, современный рубеж Sec-Fetch-Site и заголовки безопасности middleware.Secure. Канон на :1323 дополнится тремя слоями — и всё сверен с исходниками, как обычно.

Содержание урока · 11 разделов

echo cors: Same-Origin Policy и зачем нужен CORS#

Начнём с того, что CORS — не фича Echo и не фича сервера вообще, а договор с браузером. Same-Origin Policy — политика одного происхождения: скрипт со страницы https://app.example.com может свободно читать ответы только того же источника — схема, хост и порт совпадают до символа. Запрос к https://api.example.com уже кросс-сайтовый: браузер его отправит (или сначала спросит разрешения — увидим ниже), но JavaScript не получит доступ к ответу, если в ответе нет специального заголовка. Политика придумана честно: без неё любой сайт мог бы читать вашу почту в соседней вкладке — куки отправляются браузером автоматически, и ответ чужого домена был бы открыт.

Отсюда две обязанности сервера. Первая — решить, кому доверять: Access-Control-Allow-Origin в ответе перечисляет источники, которым разрешено читать. Вторая — выдержать preflight: перед нестандартным запросом браузер шлёт OPTIONS с тремя заголовками — Origin, Access-Control-Request-Method и Access-Control-Request-Headers — и ждёт ответа с разрешением, прежде чем отправить настоящий запрос. Заголовок Authorization из вчерашнего урока — нестандартный, значит, каждый кросс-доменный вызов /api/v1/tasks начинается с OPTIONS.

Echo отдаёт обе обязанности middleware.CORS: он конфигурируется списком источников, сам распознаёт preflight, сам ставит заголовки — и не зовёт next(c) там, где хватает собственных ответов. Разбор по исходникам v4.15.0 начинается с дефолтов, и там же притаился первый сюрприз.

CORSConfig v4.15.0: поле за полем#

ПолеДефолтСмысл и нюанс
AllowOrigins["*"]Список источников; и ? превращаются в regexp: . и точка
AllowOriginFuncне заданФункция origin → (bool, error); если задана — AllowOrigins игнорируется
AllowMethodsGET, HEAD, PUT, PATCH, POST, DELETEAccess-Control-Allow-Methods для preflight
AllowHeadersпустоРазрешённые заголовки; пусто — эхо Access-Control-Request-Headers
AllowCredentialsfalseAccess-Control-Allow-Credentials: true — куке и заголовку есть дорога
UnsafeWildcardOriginWithAllowCredentialsfalseЛомает защиту от * + credentials: конкретный origin вместо звёздочки
ExposeHeadersпустоЧто из ответа читает JS: X-Total-Count, X-Request-Id — перечислите явно
MaxAge0Access-Control-Max-Age; 0 — заголовок не ставится, preflight не кэшируется

middleware.CORS на каноне: конфиг под один источник

GO
// main.go — CORS для SPA на app.example.com, API на :1323
func main() {
        e := echo.New()
        e.Use(middleware.RequestID())
        e.Use(middleware.Recover())
        e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
                AllowOrigins: []string{"https://app.example.com"},
                AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodDelete},
                AllowHeaders: []string{echo.HeaderAuthorization, echo.HeaderContentType},
                AllowCredentials: true,
                MaxAge:       300,
        }))

        api := e.Group("/api", jwtMiddleware) // echo-jwt из пятнадцатого урока
        v1 := api.Group("/v1")
        v1.GET("/tasks", listTasks)
        v1.DELETE("/tasks/:id", deleteTask, requireRole("admin"))
        e.Logger.Fatal(e.Start(":1323"))
}

// preflight — браузер спрашивает разрешения на DELETE:
// curl -si -X OPTIONS -H "Origin: https://app.example.com" \
//   -H "Access-Control-Request-Method: DELETE" \
//   localhost:1323/api/v1/tasks/42
//   HTTP/1.1 204 No Content
//   Vary: Origin
//   Access-Control-Allow-Origin: https://app.example.com
//   Access-Control-Allow-Methods: GET,POST,DELETE
//   Access-Control-Allow-Credentials: true
//   Access-Control-Max-Age: 300

Конфиг — зеркало CORSConfig: три метода вместо шести дефолтных, AllowHeaders перечисляет Authorization — тот самый нестандартный заголовок, ради которого preflight вообще случается, — и AllowCredentials true, чтобы SPA с кукой сессии из четырнадцатого урока мог носить её вместе с токеном. В curl-следе видны три вещи. Первая: статус 204 No Content — тело preflight пусто по определению. Вторая: Vary: Origin — middleware добавляет его в начале каждого запроса с Origin: ответ зависит от заголовка, кэшам нельзя слепо переиспользовать. Третья: Access-Control-Allow-Origin возвращает КОНКРЕТНЫЙ источник из списка, а не звёздочку — и это важно для AllowCredentials, как увидим в отдельном блоке.

Preflight изнутри: OPTIONS, которого не было#

Читаем тело middleware в cors.go — и первое, что стоит заметить: preflight распознаётся до всяких маршрутов — req.Method == http.MethodOptions — и обрабатывается целиком внутри слоя. Шаг нулевой: res.Header().Add(echo.HeaderVary, echo.HeaderOrigin) — Vary ставится ВСЕГДА, до всяких проверок: браузеры и прокси должны знать, что ответ меняется от Origin.

Шаг первый: Origin пуст? Комментарий в исходнике честен: «No Origin provided. This is (probably) not request from actual browser - proceed executing middleware chain» — curl без Origin проходит дальше как обычный запрос, а OPTIONS без Origin получает 204 сразу: нечего разрешать, но и путь браузера, который дойдёт до auth-слоёв и застрянет там без токенов и кук, закрыт. Рядом объясняется, почему middleware НЕ зовёт next(c) для preflight вообще: «CORS requests do not have cookies / authentication headers by default, so we could get stuck in auth middlewares by calling next(c)» — звенья вроде echo-jwt из пятнадцатого урока ответили бы 401 до того, как заголовки CORS успели бы уйти.

Шаг второй — выбор значения Allow-Origin: AllowOriginFunc, если задана, иначе перебор AllowOrigins — точное совпадение, совпадение по matchSubdomain, затем regexp-паттерны из звёздочек и вопросиков, собранные на этапе конфигурации через regexp.QuoteMeta с заменой \ на . и \? на точку. Источник не разрешён? Для preflight — снова 204, но БЕЗ заголовков разрешения: браузер сам отвергнет последующий запрос, не увидев Access-Control-Allow-Origin. Заметьте изящество: echo не отвечает ошибкой — CORS-отказ существует только в браузере, на проводе его нет.

Шаг третий, успешный: заголовки разрешения, MaxAge для кэша — и return c.NoContent(http.StatusNoContent) вместо next(c). Preflight завершён, настоящий запрос пойдёт вторым рейсом.

Проверьте себя: preflight без Origin

Клиент шлёт OPTIONS /api/v1/tasks без заголовка Origin. CORS-слой смонтирован глобально через e.Use. Что ответит middleware.CORS?

AllowCredentials и звёздочка: комбинация, которой нет#

AllowCredentials true — обещание сервера: этот источник может прислать куку и Authorization, а JavaScript получит ответ целиком. Со списком конкретных источников всё просто. Но дефолт AllowOrigins — [""], и попытка совместить звёздочку с credentials — классическая дыра: «любому источнику можно присылать мои куки» противоречит самой идее Same-Origin Policy. Поэтому spec запрещает сочетание Access-Control-Allow-Origin: с Access-Control-Allow-Credentials: true — браузеры отвергают такую пару на проверке.

Echo v4.15.0 в этом вопросе заботлив, но не начальник. Док-комментарий AllowCredentials говорит прямо: «Security: avoid using AllowCredentials = true with AllowOrigins = » — со ссылкой на знаменитое исследование PortSwigger про эксплуатацию misconfigurations CORS. Код при этом не паникует и не исправляет вас: с AllowOrigins [""] middleware поставит Access-Control-Allow-Origin: и, если AllowCredentials, — Allow-Credentials: true, и браузер на такой ответ скажет «нет». Спасательный клапан есть: поле UnsafeWildcardOriginWithAllowCredentials — «UNSAFE/INSECURE: allows wildcard '' origin to be used with AllowCredentials flag» — в этом режиме echo отражает запрошенный origin вместо звёздочки, и браузер пару принимает. Название поля с «Unsafe» — честная маркировка: включать его можно только осознанно, понимая, что вы доверяете КАЖДОМУ источнику ваши credentialed-ответы. В каноне выше мы перечислили источник явно — и звёздочка не понадобилась.

Рядом — недооценённое поле ExposeHeaders: без него JavaScript не прочитает кастомные заголовки ответа. Хотите, чтобы SPA видел X-Total-Count из урока про запросы и ответы или X-Request-Id из девятого — перечислите их в ExposeHeaders, иначе браузер покажет только стандартный набор.

Проверьте себя: звёздочка плюс AllowCredentials

CORSConfig{AllowOrigins: ["*"], AllowCredentials: true} без UnsafeWildcardOriginWithAllowCredentials. SPA с чужого источника шлёт fetch с credentials: "include". Что произойдёт?

csrf echo: атака, где ваш браузер — оружие#

CORS защищает чужой сайт от чтения ваших ответов. CSRF — Cross-Site Request Forgery — другая сторона той же медали: чужой сайт заставляет браузер ОТПРАВИТЬ запрос к вашему API от имени залогиненного пользователя. Механика подлая простотой: вы залогинены в задаче-трекере, кука сессии из четырнадцатого урока лежит в jar; открываете разосланный форум «конкурсные призы», а там невидимая авто-форма — <form action="https://ваш-трекер/api/tasks/delete" method="POST"> — с отправкой при загрузке. Браузер честно постит — с вашими куками, потому что куки прикладываются к запросам независимо от того, кто инициировал страницу. Same-Origin Policy тут бессильна: она запрещает ЧИТАТЬ ответ, но не мешает ОТПРАВИТЬ запрос.

Защита строится на вопросе «докажи, что запрос породил твой собственный интерфейс». Канонический ответ — токен CSRF и паттерн double-submit: сервер кладёт случайный токен в куку _csrf, интерфейс обязан предъявить его же отдельным каналом — скрытым полем формы или заголовком X-CSRF-Token; злоумышленник с чужого сайта токен прочитать не может (Same-Origin Policy работает на вас), а значит, подделать пару «кука + канал» не в силах.

В echo за это отвечает middleware.CSRF — и в v4.15.0 он совсем не тот, что в старых курсах: с версии 4.10 его переписали, и сейчас это двухрубежная оборона. Разберём по исходникам csrf.go: сначала новый рубеж Sec-Fetch-Site, затем классический токен.

middleware.CSRF на каноне: форма задачи под защитой

GO
// main.go — CSRF для страниц с формами (канон тринадцатого урока)
func main() {
        e := echo.New()
        e.Use(middleware.RequestID(), middleware.Recover())
        e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
                TokenLookup: "form:csrf", // формы шлют скрытым полем; заголовок — "header:X-CSRF-Token"
                CookieSameSite: http.SameSiteLaxMode, // на http-локалке, как в прошлом уроке
                Skipper: func(c echo.Context) bool {
                        // API под echo-jwt CSRF не боится: чужой сайт не выставит
                        // за браузер заголовок Authorization — скипаем /api
                        return strings.HasPrefix(c.Path(), "/api")
                },
        }))
        // страницы с формами и /api/v1 под echo-jwt
}

func tasksPage(c echo.Context) error {
        // шаблон тринадцатого урока: скрытое поле несёт токен
        data := map[string]any{
                "Tasks": tasks,
                "CSRF":  c.Get("csrf"), // ContextKey дефолт: "csrf"
        }
        return c.Render(http.StatusOK, "tasks.html", data)
}

// в шаблоне tasks.html, внутри формы добавления задачи:
//   <form method="POST" action="/tasks">
//     <input type="hidden" name="csrf" value="{{ .CSRF }}">
//     <input type="text" name="title">
//     <button type="submit">Добавить</button>
//   </form>
//
// curl -si -X POST -d "title=без токена" localhost:1323/tasks
//   HTTP/1.1 400 Bad Request   {"message":"missing csrf token in the form parameter"}

Дефолты CSRFConfig v4.15.0 читаются как чек-лист: TokenLength 32 — длина генерируемого токена в символах, TokenLookup "header:X-CSRF-Token" — где искать токен клиента, ContextKey "csrf" — под каким ключом токен лежит в контексте для шаблонов, CookieName "_csrf" и CookieMaxAge 86400 — сутки жизни куки, CookieSameSite по умолчанию SameSiteDefaultMode. Скипер — важная строка канона: CSRF-защита нужна там, где аутентификация на куках, — чужой сайт может заставить браузер приложить куку, но не может выставить за него заголовок Authorization, поэтому API под echo-jwt от CSRF не страдает и обрабатывается скипером. Тело middleware: если куки _csrf нет — token = randomString(config.TokenLength), если есть — «Reuse token». Дальше развилка по методу: GET, HEAD, OPTIONS, TRACE — безопасные по RFC 7231, токен не проверяем; всё остальное — проверка через extractors, и провал даёт либо 403 ErrCSRFInvalid «invalid csrf token» (токен был, но не совпал), либо 400 с точным текстом («missing csrf token in the form parameter» / «in request header» / «in the query string»). Кука выставляется ПОСЛЕ проверки — в конце middleware, на каждом ответе.

Sec-Fetch-Site: рубеж, который появился у браузеров#

Переписанный CSRF v4.15.0 начинает работу не с токена, а с заголовка Sec-Fetch-Site — того, что современные браузеры добавляют к каждому запросу сами, и подделать его из JavaScript нельзя. Проверка checkSecFetchSiteRequest в csrf.go перебирает значения с комментариями, которые стоит запомнить: same-origin — точное совпадение источника, разрешаем всегда; none — прямая навигация по адресной строке или закладке, тоже пропускаем; same-site — тот же регистрируемый домен, но поддомен или другой порт; cross-site — чужой сайт. Для опасных методов only same-origin и none проходят автоматически; same-site и cross-site попадают под подробный разбор.

Дальше — тонкая настройка для честных случаев. TrustedOrigins перечисляет источники, которым верят при совпадении Origin — классика OAuth-колбэков и интеграций; AllowSecFetchSiteFunc — свой вердикт для спорных значений. Если Sec-Fetch-Site равен same-site, middleware НЕ ругается, а «fall back to legacy token» — уходит в классическую проверку токена; cross-site с несовпадающим Origin блокируется сразу. Дизайн приятный: новые браузеры получают быстрый вердикт по метаданным, старые — двойную отправку токена, и оба рубежа работают в одном middleware.

Для вас это значит две вещи. Первая: при отладке из curl заголовка Sec-Fetch-Site нет — проверка молча пропускает (secFetchSite == "" → false, nil), и вы попадаете на токенный рубеж: подкладывайте X-CSRF-Token или поле csrf, как в curl-примере выше. Вторая: same-site — это «поддомен или другой порт»: ваш own staging на соседнем порту считается same-site и обязан предъявить токен, как чужак. Планируйте TrustedOrigins заранее, а не после первого интегративного теста.

Проверьте себя: POST без csrf-токена и с неверным

middleware.CSRF с дефолтным TokenLookup. Приходят два POST-а на /tasks: первый вообще без X-CSRF-Token, второй с заголовком, не совпавшим с кукой _csrf. Какие ответы?

GET без куки и токена: что делает CSRF

Прочитайте код и выберите, что он выведет:

// middleware.CSRF смонтирован глобально. Первый заход свежего клиента:
// GET /tasks — ни куки _csrf, ни заголовка X-CSRF-Token нет.

// что вернёт сервер?

Заголовки безопасности: middleware.Secure#

CORS и CSRF отвечают за то, кому разрешено и что доказано. Третий слой тише: заголовки, которые браузер исполняет сам, — и в echo за них отвечает middleware.Secure. DefaultSecureConfig v4.15.0 короток: XSSProtection "1; mode=block" — X-XSS-Protection, ContentTypeNosniff "nosniff" — запрет на угадывание типа содержимого, XFrameOptions "SAMEORIGIN" — запрет чужим сайтам встраивать ваши страницы в iframe, кликджекинг закрыт. HSTSMaxAge по умолчанию 0 — Strict-Transport-Security не ставится вовсе.

Тело SecureWithConfig — цепочка if по пустоте значений: поле не пусто — заголовок ставится, пусто — заголовка нет. Самая интересная ветка — HSTS: заголовок Strict-Transport-Security появляется только при двух условиях сразу — HSTSMaxAge не ноль И соединение HTTPS: проверка c.IsTLS() или заголовок X-Forwarded-Proto: https от прокси. На http-локалке :1323 его не увидите — и это правильно: HSTS приказывает браузеру ходить только по HTTPS, на http-хосте он бессмыслен. Опции includeSubdomains, preload и CSPReportOnly — для постепенного внедрения Content-Security-Policy в режиме «репортить, не блокировать».

CSP — отдельная история: ContentSecurityPolicy из конфига уезжает в заголовок Content-Security-Policy, и его политика ограничивает, откуда странице можно грузить скрипты, стили и что выполнять инлайн. Полноценный CSP — тема отдельного разговора, но заготовка в Secure есть: default-src 'self' — разумная первая ступень. Заметьте и честную оговорку из мира браузеров: X-XSS-Protection — наследие: современные движки его поддержку удалили, но заголовок безвреден и старые браузеры ещё прикрывает; настоящая XSS-защита сегодня — это CSP и экранирование шаблонов из двенадцатого урока.

Канон: три слоя безопасности на :1323

GO
// main.go — порядок слоёв: Secure раньше всех, CSRF на страницы, CORS на API
func main() {
        e := echo.New()
        e.Use(middleware.RequestID())
        e.Use(middleware.Recover())
        e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
                ContentTypeNosniff: "nosniff",
                XFrameOptions:      "SAMEORIGIN",
                // XSSProtection и HSTS не задаём: дефолт "1; mode=block",
                // HSTS на http-локалке всё равно не появится — нужен TLS
        }))
        e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
                TokenLookup:    "header:X-CSRF-Token,form:csrf", // SPA — заголовок, форма — поле
                CookieSameSite: http.SameSiteLaxMode,
                Skipper: func(c echo.Context) bool { // API под echo-jwt — вне csrf-рубежа
                        return strings.HasPrefix(c.Path(), "/api")
                },
        }))
        e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
                AllowOrigins: []string{"https://app.example.com"},
                AllowHeaders: []string{echo.HeaderAuthorization, echo.HeaderXCSRFToken},
                AllowCredentials: true,
        }))
        // ...маршруты канона...
}

// curl -si localhost:1323/ | head -8
//   HTTP/1.1 200 OK
//   X-Content-Type-Options: nosniff
//   X-Frame-Options: SAMEORIGIN
//   X-XSS-Protection: 1; mode=block
//   Set-Cookie: _csrf=qT4m...; Expires=...; HttpOnly=false

Порядок e.Use — это и порядок заголовков в ответе, и порядок защиты: Secure ставит заголовки каждому ответу и не блокирует ничего, CSRF и CORS решают судьбу запроса дальше. Обратите внимание на TokenLookup с двумя источниками: «header:X-CSRF-Token,form:csrf» обслуживает обоих клиентов — SPA шлёт заголовок, HTML-форма из тринадцатого урока кладёт скрытое поле; перебор extractors в csrf.go проверит оба и возьмёт первый непустой. Скипер у CSRF — тот же принцип из канона выше: /api живёт под echo-jwt, где CSRF не угрожает. В curl-следе видны три заголовка Secure-дефолта; HSTS отсутствует — на http его по исходнику не бывает. И нюанс куки _csrf: CookieHTTPOnly в дефолте false, и это осознанно — токен должен быть прочитан JavaScript-ом SPA, чтобы уехать заголовком; HttpOnly=true он был бы недоступен самому интерфейсу.

Что из заголовков Secure появится на localhost

Прочитайте код и выберите, что он выведет:

e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
        HSTSMaxAge: 86400,
        ContentSecurityPolicy: "default-src 'self'",
}))

// сервер на http://localhost:1323, запрос без прокси. curl -si
// какие из заголовков будут в ответе?

Заполните пропуски: два рубежа CSRF

Соберите конфиг CSRF и пару в шаблоне: токен в куке, токен в поле формы, сравнение на опасном методе.

e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ TokenLookup: ":X-CSRF-Token", // источник по умолчанию ContextKey: "", // ключ в контексте для шаблонов CookieName: "", // кука double-submit CookieMaxAge: , // сутки в секундах CookieSameSite: http., // мягкий режим для локалки })) // в tasks.html: <input type="hidden" name="csrf" value="{{ . }}"> // в POST-обработчике формы токен сверится с кукой через // subtle.([]byte(token), []byte(clientToken)) == 1

Соберите кросс-сайтовый POST под двумя рубежами

Расставьте порядок: preflight до настоящего запроса, проверка токена до обработчика, ответ после сравнения.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: CORS, CSRF, Secure

Термин

Same-Origin Policy и роль CORS

нажмите, чтобы перевернуть
Определение

SOP: скрипт читает ответы только своего источника (схема+хост+порт). Отправить запрос на чужой домен можно, прочитать ответ — нет без заголовков разрешения. CORS — управляемая уступка: Access-Control-Allow-Origin перечисляет источники, которым JS видит ответ. Отказ CORS не существует на проводе: echo не ставит заголовки, и браузер блокирует сам.

1 / 6

Что дальше: контракт API, который не стыдно показать#

Канон на :1323 обзавёлся тремя рубежами. middleware.CORS впускает SPA с app.example.com: preflight на OPTIONS получает 204 без похода в auth-слои, Allow-Origin — конкретный источник из списка, звёздочка с credentials разоблачена и не использована. middleware.CSRF охраняет формы из тринадцатого урока двойной отправкой: кука _csrf против заголовка или скрытого поля, Sec-Fetch-Site отсекает чужаков до проверки токена, а curl-тесты честно получают 400 на отсутствие и 403 на подделку. middleware.Secure ставит nosniff, SAMEORIGIN и X-XSS-Protection каждому ответу, воздерживаясь от HSTS на http-локалке. Всё сверено с cors.go, csrf.go и secure.go v4.15.0 — от ветки if origin == "" до «ugly part to preserve backwards compatible errors».

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите канон go run main.go и прогоните три матрицы curl: preflight с Origin и без, POST формы с полем csrf и без него, запросы с Sec-Fetch-Site: cross-site — и убедитесь, что в логах ErrorHandler каждая причина своя. Обратная ссылка: access-токен из пятнадцатого урока теперь легален для SPA на чужом домене — AllowHeaders его пропускает через preflight.

Следующий, семнадцатый урок курса echo — RESTful-практики: версии api, статус-коды, пагинация, идемпотентность. Ресурсы, гварды и заголовки мы уже построили — осталось привести ответы API к единому предсказуемому контракту: что в теле списка, какой статус у создания и как клиент переживает ретраи. Навигация по курсу — на странице курса Echo; выдача токена, которую здесь пропустили через preflight, — в уроке про JWT; механика кук и SameSite, на которой стоит double-submit, — в уроке про cookie и сессии; каталог встроенных слоёв, где эти middleware значились одной строкой, — в уроке про встроенные middleware; параллельный разбор CORS в fiber — в уроке fiber про CORS — для сравнения конфигов, но не как источник фактов об echo.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • CORS — договор с браузером поверх Same-Origin Policy: скрипт читает ответы чужого источника только при Access-Control-Allow-Origin. Echo отдаёт это middleware.CORS v4.15.0: дефолт AllowOrigins ["*"], шесть методов, AllowCredentials false, MaxAge 0 — заголовок не ставится; Vary: Origin — всегда.
  • Preflight = OPTIONS: без Origin — 204 (или next(c) для не-preflight); Origin не разрешён — 204 без заголовков, отказ видит только браузер; разрешён — заголовки и 204. next(c) для preflight не зовётся: без кук и токенов auth-слои ответили бы раньше заголовков. Источники: точное совпадение, matchSubdomain, regexp из * и ?.
  • AllowCredentials true + AllowOrigins ["*"] — отвергается браузерами; echo не паникует, а UnsafeWildcardOriginWithAllowCredentials отражает конкретный origin — флаг с «Unsafe» в имени, включать осознанно. ExposeHeaders — единственный путь для JS к X-Total-Count и X-Request-Id.
  • CSRF — атака чужого сайта вашими куками; защита — double-submit: случайный токен в куке _csrf и тот же токен в заголовке X-CSRF-Token или скрытом поле. middleware.CSRF v4.15.0: TokenLength 32, ContextKey «csrf», сутки куки; безопасные методы GET/HEAD/OPTIONS/TRACE токен не проверяют, но куку выставляют.
  • Sec-Fetch-Site — новый рубеж CSRF: same-origin и none пропускаются, same-site уходит в проверку токена, cross-site блокируется; TrustedOrigins и AllowSecFetchSiteFunc — для честных исключений. Ошибки: нет токена — 400 «missing csrf token in...», не совпал — 403 ErrCSRFInvalid «invalid csrf token»; сравнение — subtle.ConstantTimeCompare.
  • middleware.Secure ставит X-XSS-Protection «1; mode=block», X-Content-Type-Options «nosniff», X-Frame-Options «SAMEORIGIN» каждому ответу; HSTS — только при TLS (IsTLS или X-Forwarded-Proto: https) и HSTSMaxAge != 0; CSP — по непустоте строки, CSPReportOnly — режим репортов без блокировки.

Домашнее задание

Соберите канон урока: Secure, CSRF на страницы, CORS на API с AllowHeaders под Authorization и X-CSRF-Token. Первое: снимите curl-след preflight — OPTIONS с Origin app.example.com и Access-Control-Request-Method: DELETE; проверьте 204, Vary: Origin и Allow-Methods, затем повторите с Origin evil.example.com и убедитесь, что заголовков разрешения нет, хотя статус всё равно 204. Второе: пройдите форму задачи из тринадцатого урока без поля csrf (400 «missing csrf token in the form parameter»), с неверным значением (403 «invalid csrf token») и с честным токеном из страницы; объясните по исходнику, почему оба статуса разные. Третье: поставьте AllowOrigins ["*"] с AllowCredentials true, уберите unsafe-флаг и попробуйте fetch с credentials из браузера — опишите по CORS-спеке, что увидит консоль; верните явный список. Четвёртое: добавьте ExposeHeaders с X-Total-Count и X-Request-Id и проверьте из консоли браузера, что fetch на кросс-доменном списке их наконец читает. Пятое: включите HSTSMaxAge 86400 на http-локалке, убедитесь, что заголовка нет, затем добавьте X-Forwarded-Proto: https в curl-запрос и объясните по ветке SecureWithConfig, почему заголовок появился.

Личные заметкиПусто
Хранятся только в вашем браузере (Echo · CORS, CSRF и безопасность: middleware.CORS, csrf-токены и заголовки Secure)

Вопросы и ответы по уроку

Preflight возвращает 404/405 вместо 204 — что сломано?

Почти всегда порядок middleware и точки монтирования. CORS-слой обязан видеть запрос раньше auth-слоёв и раньше групп: e.Use(middleware.CORS()) глобально, а не на группе с echo-jwt, — иначе preflight без токена получит 401 от аутентификации. Если 404 — вы смонтировали CORS на конкретном маршруте, а OPTIONS прилетает на путь с другим набором; помните, что middleware.CORS не зовёт next(c) для preflight и сам отвечает 204, но для этого должен выполняться. И проверьте, что запрос OPTIONS вообще доходит: прокси и nginx иногда съедают его по собственной конфигурации.

Почему curl проходит без csrf-токена, а форма — нет?

Два рубежа CSRF v4.15.0 работают по очереди. Сначала checkSecFetchSiteRequest: в curl заголовка Sec-Fetch-Site нет — secFetchSite == "" вернёт (false, nil), и middleware уходит на классический токенный рубеж. Для GET/HEAD/OPTIONS/TRACE проверка пропускается — curl читает страницы свободно; для POST нужен токен: без X-CSRF-Token — 400 «missing csrf token in request header», ровно как у формы. Если ваш curl проходит там, где форма падает — проверьте маршрут: возможно, POST уходит на путь вне группы с CSRF-слоем или скипается конфигурацией; и сверьте пару — кука _csrf из первого GET и заголовок/поле из текущего запроса должны быть одним значением.

CSRF-куке нужен HttpOnly? А SameSite?

HttpOnly — нет: в double-submit токен должен прочитать JavaScript SPA, чтобы отправить его заголовком; HttpOnly=true сделает куку недоступной документному скрипту и сломает браузерный путь (CookieHTTPOnly в дефолте false не случайно). SameSite — да: CookieSameSite Lax (или Strict для чистых сессий) не даёт куке уехать в кросс-сайтовом POST-е — механика констант из прошлого урока. А SameSite None middleware принудительно усиливает: в ToMiddleware стоит if config.CookieSameSite == http.SameSiteNoneMode { config.CookieSecure = true } — None без HTTPS запрещён и на уровне CSRF-конфига.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

GORM25 уроков~14 чпрактика25 уроков уже доступны

GORM: ORM-библиотека для Go

Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.

  • Подключать БД и настраивать пул соединений
  • Описывать модели и связи: 1:1, 1:N, M:N
  • Писать CRUD и сложные выборки chainable-стилем
  • Контролировать N+1 через Preload