Содержание урока · 11 разделов
- echo cors: Same-Origin Policy и зачем нужен CORS
- CORSConfig v4.15.0: поле за полем
- Preflight изнутри: OPTIONS, которого не было
- AllowCredentials и звёздочка: комбинация, которой нет
- csrf echo: атака, где ваш браузер — оружие
- Sec-Fetch-Site: рубеж, который появился у браузеров
- Заголовки безопасности: middleware.Secure
- Что дальше: контракт API, который не стыдно показать
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 игнорируется |
| AllowMethods | GET, HEAD, PUT, PATCH, POST, DELETE | Access-Control-Allow-Methods для preflight |
| AllowHeaders | пусто | Разрешённые заголовки; пусто — эхо Access-Control-Request-Headers |
| AllowCredentials | false | Access-Control-Allow-Credentials: true — куке и заголовку есть дорога |
| UnsafeWildcardOriginWithAllowCredentials | false | Ломает защиту от * + credentials: конкретный origin вместо звёздочки |
| ExposeHeaders | пусто | Что из ответа читает JS: X-Total-Count, X-Request-Id — перечислите явно |
| MaxAge | 0 | Access-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 и пару в шаблоне: токен в куке, токен в поле формы, сравнение на опасном методе.
Соберите кросс-сайтовый POST под двумя рубежами
Расставьте порядок: preflight до настоящего запроса, проверка токена до обработчика, ответ после сравнения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Первая: AllowOrigins ["*"] с AllowCredentials true — браузер отвергает пару; без UnsafeWildcardOriginWithAllowCredentials не спастись, а с ним вы доверяете всем — держите явный список. Вторая: забыли AllowHeaders — preflight пройдёт, а настоящий запрос с Authorization умрёт в браузере без всякой ошибки сервера; перечисляйте Authorization и X-CSRF-Token явно. Третья: CSRF-кука без SameSite на кросс-сайтовых отправках не приедет — ставьте Lax для форм, помня механику из прошлого урока. Четвёртая: Sec-Fetch-Site в curl отсутствует — тесты падают на токенном рубеже с 400 «missing csrf token», и это не баг, а второй рубеж в действии. Пятая: ExposeHeaders пуст — SPA не увидит X-Total-Count и X-Request-Id, сколько бы вы их ни отправляли; кастомные заголовки читаются только из явного списка. Шестая: HSTS без TLS и без X-Forwarded-Proto: https молча не ставится — на проде за nginx проверяйте заголовки curl-ом, а не конфигом.
Карточки: CORS, CSRF, Secure
Что дальше: контракт 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, почему заголовок появился.
Личные заметкиПусто
Вопросы и ответы по уроку
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-конфига.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload