Три уязвимости этой статьи живут на границе между сервером и браузером. Сервер отвечает «иди туда», «я выполнил запрос» или «этому сайту можно читать мой ответ», а браузер честно исполняет. Ошибка в каждом случае на сервере: он доверился адресу из запроса, cookie, которую браузер приложил сам, или заголовку Origin, который отразил не глядя. Как то же самое выглядит со стороны страницы, разобрано в статье Браузер и сеть; здесь только серверная половина.
Открытый редирект: как устроена атака и чем грозитспросят на собеседовании
Страница входа маркетплейса принимает адрес возврата: https://shop.example/login?next=/orders/42. После входа сервер отвечает 302 с Location: /orders/42. Атакующий рассылает письмо «ваш заказ задержан» со ссылкой https://shop.example/login?next=https://evil.example/login. Домен в ссылке настоящий, страница входа настоящая, пароль покупатель вводит у вас. А потом сервер сам отправляет его на evil.example, где копия формы пишет «сессия истекла, войдите ещё раз». Второй пароль уходит атакующему.
Это CWE-601 «URL Redirection to Untrusted Site ('Open Redirect')». Сам по себе редирект ничего не ломает на сервере, его ценность для атакующего в доверенном домене: фишинг с вашим адресом в ссылке, обход фильтров «только ссылки на shop.example» и, главное, кража кодов в OAuth2. RFC 9700 (Best Current Practice for OAuth 2.0 Security, январь 2025) прямо пишет: «Open redirectors can enable exfiltration of authorization codes and access tokens», и требует от сервера авторизации сравнивать redirect_uri с зарегистрированным точным совпадением строки («MUST utilize exact string matching»). Если redirect_uri разрешён по префиксу https://shop.example/, а на shop.example есть открытый редирект, код авторизации уезжает на чужой домен вместе с переходом. Как redirect_uri участвует в потоке, показано в статье Authorization Code Flow, а короткая заметка о редиректе есть в слоях проверки.
Как выглядит в коде
Типичная «проверка» разбирает адрес библиотечным парсером и пропускает его, если хоста нет («значит, относительный путь») или хост похож на свой. Беда в том, что парсер сервера и браузер читают одну строку по-разному. Браузер разбирает Location по URL Standard WHATWG: обратную косую черту в схемах http и https считает прямой, табуляцию и перевод строки выбрасывает, а http:evil.example на странице с https читает как http://evil.example/. Парсеры java.net.URI, net/url и urllib.parse устроены иначе, и в документации urllib.parse это сказано прямо: «The urlsplit() and urlparse() APIs do not perform validation of inputs».
Ниже обе версии рядом: наивная (хост пустой или оканчивается на shop.example) и safeRedirect, которая пропускает только относительный путь своего сайта. В Node в последней колонке видно, куда ушёл бы браузер: встроенный URL реализует тот же стандарт WHATWG.
live example
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
public class SafeRedirect {
static final String FALLBACK = "/";
static String naiveRedirect(String target) {
try {
String host = new URI(target).getHost();
if (host == null || host.endsWith("shop.example")) {
return target;
}
} catch (URISyntaxException e) {
return FALLBACK;
}
return FALLBACK;
}
static String safeRedirect(String target) {
if (target == null || !target.startsWith("/") || target.startsWith("//")) {
return FALLBACK;
}
for (char c : target.toCharArray()) {
if (c == '\\' || c < 0x20 || c == 0x7f) {
return FALLBACK;
}
}
try {
URI uri = new URI(target);
if (uri.getScheme() != null || uri.getRawAuthority() != null) {
return FALLBACK;
}
} catch (URISyntaxException e) {
return FALLBACK;
}
return target;
}
public static void main(String[] args) {
List<String> targets = List.of(
"/orders/42", "/orders/42?tab=paid#items",
"https://evil.example/login", "//evil.example/login",
"/\\evil.example/login", "\\\\evil.example/login",
"http:evil.example/login", "/\t/evil.example",
"javascript:alert(1)", "https://evilshop.example/login");
for (String t : targets) {
System.out.printf("%-32s наивно -> %-32s safe -> %s%n",
t.replace("\t", "\\t"), naiveRedirect(t).replace("\t", "\\t"), safeRedirect(t));
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import (
"fmt"
"net/url"
"strings"
)
const fallback = "/"
func naiveRedirect(target string) string {
u, err := url.Parse(target)
if err != nil {
return fallback
}
if u.Hostname() == "" || strings.HasSuffix(u.Hostname(), "shop.example") {
return target
}
return fallback
}
func safeRedirect(target string) string {
if !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") {
return fallback
}
for _, c := range target {
if c == '\\' || c < 0x20 || c == 0x7f {
return fallback
}
}
u, err := url.Parse(target)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
return fallback
}
return target
}
func main() {
targets := []string{
"/orders/42", "/orders/42?tab=paid#items",
"https://evil.example/login", "//evil.example/login",
"/\\evil.example/login", "\\\\evil.example/login",
"http:evil.example/login", "/\t/evil.example",
"javascript:alert(1)", "https://evilshop.example/login",
}
show := strings.NewReplacer("\t", `\t`)
for _, t := range targets {
fmt.Printf("%-32s наивно -> %-32s safe -> %s\n",
show.Replace(t), show.Replace(naiveRedirect(t)), safeRedirect(t))
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const { URL } = require("url");
const FALLBACK = "/";
const BASE = new URL("https://shop.example/");
function naiveRedirect(target) {
try {
const host = new URL(target).hostname;
if (host === "" || host.endsWith("shop.example")) return target;
} catch {
if (target.startsWith("/")) return target;
}
return FALLBACK;
}
function safeRedirect(target) {
if (typeof target !== "string" || !target.startsWith("/") || target.startsWith("//")) {
return FALLBACK;
}
if (/[\\\u0000-\u001f\u007f]/.test(target)) return FALLBACK;
const resolved = new URL(target, BASE);
if (resolved.origin !== BASE.origin) return FALLBACK;
return resolved.pathname + resolved.search + resolved.hash;
}
const targets = [
"/orders/42", "/orders/42?tab=paid#items",
"https://evil.example/login", "//evil.example/login",
"/\\evil.example/login", "\\\\evil.example/login",
"http:evil.example/login", "/\t/evil.example",
"javascript:alert(1)", "https://evilshop.example/login",
];
const show = (s) => s.replace("\t", "\\t");
for (const t of targets) {
const browser = new URL(t, BASE).href;
console.log(
`${show(t).padEnd(32)} наивно -> ${show(naiveRedirect(t)).padEnd(32)} safe -> ${safeRedirect(t).padEnd(26)} браузер ушёл бы на ${browser}`
);
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
from urllib.parse import urlsplit
FALLBACK = "/"
def naive_redirect(target: str) -> str:
host = urlsplit(target).hostname
if host is None or host.endswith("shop.example"):
return target
return FALLBACK
def safe_redirect(target: str) -> str:
if not target.startswith("/") or target.startswith("//"):
return FALLBACK
if any(c == "\\" or ord(c) < 0x20 or ord(c) == 0x7F for c in target):
return FALLBACK
parts = urlsplit(target)
if parts.scheme or parts.netloc:
return FALLBACK
return target
targets = [
"/orders/42", "/orders/42?tab=paid#items",
"https://evil.example/login", "//evil.example/login",
"/\\evil.example/login", "\\\\evil.example/login",
"http:evil.example/login", "/\t/evil.example",
"javascript:alert(1)", "https://evilshop.example/login",
]
for t in targets:
show = lambda s: s.replace("\t", "\\t")
print(f"{show(t):32} наивно -> {show(naive_redirect(t)):32} safe -> {safe_redirect(t)}")
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Наивная версия в каждом языке ошибается на своих строках, и это главный вывод: проверка «по разобранному хосту» зависит от парсера, а решает браузер. javascript:alert(1) и http:evil.example/login хоста не имеют ни в одном серверном парсере и проходят как «локальные». https://evilshop.example проходит проверку по суффиксу. //evil.example и /\evil.example в Node наивно выглядят путём, а браузер уходит на evil.example.
Отдельно про https:evil.example: на странице с той же схемой браузер прочтёт его как относительный путь /evil.example, но серверный парсер видит схему без хоста. Ставить безопасность на такое совпадение нельзя: стоит сменить схему (http:) или страницу, и строка уводит наружу.
Как чинить открытый редиректспросят на собеседовании
OWASP Unvalidated Redirects and Forwards Cheat Sheet даёт порядок предпочтений:
- «Simply avoid using redirects and forwards». Часто после входа достаточно вести в личный кабинет.
- Не принимать адрес вообще: «have the user provide short name, ID or token which is mapped server-side to a full target URL». Для маркетплейса это
?next=order&id=42, а адрес собирает сервер. - Если нужен путь возврата, принимать только относительный путь своего сайта, как
safeRedirectвыше: начинается с одного/, второй символ не/, нет\и управляющих символов, у разобранного адреса нет схемы и хоста. Отказ ведёт на/, а не в ошибку 500. - Если внешний адрес действительно нужен (переход к партнёру-перевозчику), сравнивать схему, хост и порт с явным списком: «Compare the parsed scheme, canonical host, and effective port against an explicit allowlist», «Do not use raw prefix or suffix matching», «Reject userinfo», и перенаправлять на проверенный адрес, а не на исходную строку.
Чего не делать: не перенаправлять на хост, разобранный из ввода, не сравнивать строки через startsWith("https://shop.example") (пропустит https://shop.example.evil) и не декодировать адрес после проверки. Для переходов на внешние сайты OWASP предлагает промежуточную страницу «вы покидаете shop.example» с адресом назначения на виду.
CSRF: когда он есть у API, а когда нетспросят на собеседовании
CWE-352 «Cross-Site Request Forgery (CSRF)»: чужая страница заставляет браузер жертвы отправить запрос, и браузер сам прикладывает cookie. Для API вопрос решается одним: как клиент передаёт удостоверение.
- Cookie-сессия (BFF держит сессию в cookie, SPA ходит на
/api/ordersсcredentials: "include"). CSRF есть. Страницаevil.exampleотправляет формуPOST https://shop.example/api/orders/42/cancel, cookie уходит вместе с ней. OWASP CSRF Prevention Cheat Sheet: «Since browser requests automatically include all cookies including session cookies, this attack works unless proper authorization is used». - Bearer-токен в заголовке
Authorization, который клиент ставит сам. CSRF нет: браузер не добавит этот заголовок к чужой форме, а чужой скрипт не может его установить без вашего разрешения CORS. Защита держится ровно до тех пор, пока токен не переехал в cookie. - Токен в cookie, сервер читает его из cookie. Это та же cookie-сессия, только без состояния на сервере. Справочник по CSRF у одного из распространённых фреймворков разбирает этот случай отдельно: stateless не значит защищён, а basic-аутентификация уязвима так же, потому что браузер прикладывает логин и пароль сам.
- Сервис без браузерных клиентов (сервис-к-сервису, мобильное приложение). Там же: «If you are creating a service that is used only by non-browser clients, you likely want to disable CSRF protection».
Где хранить токен на клиенте, чтобы не получить ни XSS-кражу, ни CSRF, разобрано в статье Хранение токенов на клиенте.
Защита от CSRF: токен, свой заголовок, Originспросят на собеседовании
SameSite не замена защите. Атрибут cookie SameSite описан в RFC 6265bis, который на октябрь 2026 года всё ещё Internet-Draft (ревизия -22, одобрен и ждёт в очереди RFC Editor). Strict не отправляет cookie с запросами, начатыми чужим сайтом. Lax отправляет её при переходе верхнего уровня безопасным методом, то есть по ссылке GET. Отсюда первое ограничение: «If any state-changing operation in the application is reachable via a GET request, SameSite=Lax will not stop it» (OWASP). Второе: часть браузеров ставит Lax по умолчанию, но, по MDN, в смягчённом виде, где cookie уходит и с POST, если ей не больше двух минут. Третье: SameSite про сайт, а не про источник. sellers.shop.example и shop.example один сайт (MDN: Site), и витрина продавца с чужим HTML на поддомене обходит даже Strict. OWASP называет SameSite «useful as a defense-in-depth control but it does not replace a proper CSRF defense in most deployments».
Synchronizer token. Сервер кладёт случайный токен в сессию, отдаёт странице, а каждый изменяющий запрос возвращает его в заголовке или поле формы. Чужая страница токен прочитать не может. Подходит, когда есть серверная сессия.
Double-submit cookie. Токен лежит в cookie и дублируется в заголовке, сервер сравнивает два значения. Наивный вариант OWASP не рекомендует: он «bypassable by an attacker who can write cookies on the target domain» (тот же поддомен с чужим HTML). Рабочий вариант подписанный: токен связан с сессией HMAC-подписью, а cookie с префиксом __Host- нельзя выставить с поддомена.
Свой заголовок. Для API, где клиент свой SPA, OWASP предлагает простейшее: «A user-friendly defense that is particularly well suited for AJAX or API endpoints is the use of a custom request header. No token is needed for this approach». Форма заголовок поставить не умеет, а fetch с нестандартным заголовком на другой источник вызывает preflight, который ваш CORS не одобрит. Условие одно: CORS не должен разрешать чужие источники, иначе заголовок поставят. Заодно сервер требует Content-Type: application/json: форма с enctype="text/plain" умеет прислать тело, похожее на JSON.
Origin и Sec-Fetch-Site. OWASP называет Sec-Fetch-Site «the primary signal for CSRF protection» с запасным вариантом через Origin и Referer. Две ловушки: «Missing headers and Origin: null are not evidence of a same-origin request», и null нельзя вносить в список доверенных. В Go 1.25 эта логика есть в стандартной библиотеке: http.CrossOriginProtection отклоняет небезопасные межсайтовые запросы по Sec-Fetch-Site и сравнению Origin с Host, а GET, HEAD, OPTIONS пропускает всегда, поэтому они не должны ничего менять.
CORS: когда настройка сама становится дыройспросят на собеседовании
CORS ничего не защищает, он ослабляет политику одного источника: разрешает чужой странице читать ваш ответ. Ошибка в нём попадает в CWE-942 «Permissive Cross-domain Security Policy with Untrusted Domains». Четыре серверные ошибки встречаются чаще всего.
*вместе с cookie не работает, и это хорошо. MDN: на запрос с учётными данными сервер «must specify an origin», приAccess-Control-Allow-Origin: *браузер блокирует ответ и не сохраняетSet-Cookie. Фреймворки отказываются от этой комбинации сами.- Отражение
Originи есть настоящая дыра. Чтобы «починить» ошибку из предыдущего пункта, сервер копируетOriginзапроса вAccess-Control-Allow-Originи добавляетAccess-Control-Allow-Credentials: true. Теперь любая страница читает/api/ordersпокупателя с его cookie: адреса, телефоны, суммы. nullв списке. По MDNOrigin: nullприсылают, в частности, iframe сsandboxбезallow-same-originи документы изdata:. Такой iframe атакующий вставит на свою страницу, так что разрешитьnullзначит разрешить всех.- Регулярка вместо сравнения.
https://shop.exampleбез якорей и с неэкранированной точкой совпадает сhttps://shop.example.evilиhttps://shopXexample.
Пример ниже печатает решения трёх вариантов: отражение, регулярка и точное совпадение со списком.
live example
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
public class OriginCheck {
static final Pattern LOOSE = Pattern.compile("https://shop.example");
static final Set<String> ALLOWED = Set.of("https://shop.example", "https://seller.shop.example");
static String reflect(String origin) {
return origin;
}
static String looseRegex(String origin) {
return LOOSE.matcher(origin).find() ? origin : null;
}
static String exactMatch(String origin) {
return ALLOWED.contains(origin) ? origin : null;
}
static String header(String value) {
return value == null ? "без ACAO" : "ACAO: " + value;
}
public static void main(String[] args) {
List<String> origins = List.of(
"https://shop.example", "https://shop.example.evil",
"https://shopXexample", "null", "http://shop.example");
for (String o : origins) {
System.out.printf("%-26s | отражение: %-33s | regex: %-33s | точно: %s%n",
o, header(reflect(o)), header(looseRegex(o)), header(exactMatch(o)));
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import (
"fmt"
"regexp"
)
var loose = regexp.MustCompile(`https://shop.example`)
var allowed = map[string]bool{
"https://shop.example": true,
"https://seller.shop.example": true,
}
func reflect(origin string) string { return origin }
func looseRegex(origin string) string {
if loose.MatchString(origin) {
return origin
}
return ""
}
func exactMatch(origin string) string {
if allowed[origin] {
return origin
}
return ""
}
func header(value string) string {
if value == "" {
return "без ACAO"
}
return "ACAO: " + value
}
func main() {
origins := []string{
"https://shop.example", "https://shop.example.evil",
"https://shopXexample", "null", "http://shop.example",
}
for _, o := range origins {
fmt.Printf("%-26s | отражение: %-33s | regex: %-33s | точно: %s\n",
o, header(reflect(o)), header(looseRegex(o)), header(exactMatch(o)))
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const LOOSE = /https:\/\/shop.example/;
const ALLOWED = new Set(["https://shop.example", "https://seller.shop.example"]);
const reflect = (origin) => origin;
const looseRegex = (origin) => (LOOSE.test(origin) ? origin : null);
const exactMatch = (origin) => (ALLOWED.has(origin) ? origin : null);
const header = (value) => (value === null ? "без ACAO" : `ACAO: ${value}`);
const origins = [
"https://shop.example", "https://shop.example.evil",
"https://shopXexample", "null", "http://shop.example",
];
for (const o of origins) {
console.log(
`${o.padEnd(26)} | отражение: ${header(reflect(o)).padEnd(33)} | regex: ${header(looseRegex(o)).padEnd(33)} | точно: ${header(exactMatch(o))}`
);
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
import re
from typing import Optional
LOOSE = re.compile(r"https://shop.example")
ALLOWED = {"https://shop.example", "https://seller.shop.example"}
def reflect(origin: str) -> Optional[str]:
return origin
def loose_regex(origin: str) -> Optional[str]:
return origin if LOOSE.search(origin) else None
def exact_match(origin: str) -> Optional[str]:
return origin if origin in ALLOWED else None
def header(value: Optional[str]) -> str:
return "без ACAO" if value is None else f"ACAO: {value}"
origins = [
"https://shop.example", "https://shop.example.evil",
"https://shopXexample", "null", "http://shop.example",
]
for o in origins:
print(f"{o:26} | отражение: {header(reflect(o)):33} | regex: {header(loose_regex(o)):33} | точно: {header(exact_match(o))}")
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Точное совпадение с множеством строк решает все четыре случая: схема, хост и порт сравниваются целиком, null в списке нет. Когда ответ зависит от Origin, MDN требует добавить Vary: Origin, иначе кэш отдаст разрешение одного источника другому. Подстановочные шаблоны поддоменов (https://*.shop.example) открывают API для каждого поддомена, включая витрины продавцов; без них обычно можно обойтись.
Подключение во фреймворке
Обвязка без запуска: CORS со списком источников и CSRF для cookie-сессии.
@Configuration
@EnableWebSecurity
public class ApiSecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()));
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://shop.example", "https://seller.shop.example"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Spring Security включает CSRF по умолчанию. CookieCsrfTokenRepository пишет cookie XSRF-TOKEN и ждёт заголовок X-XSRF-TOKEN (документация). "*" в setAllowedOrigins вместе с setAllowCredentials(true) не пройдёт: validateAllowCredentials бросает IllegalArgumentException (CorsConfiguration). csrf.disable() оправдан только для API на Bearer-токенах.
func routes(orders http.Handler) (http.Handler, error) {
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://shop.example", "https://seller.shop.example"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "X-Requested-With"},
AllowCredentials: true,
}))
csrf := http.NewCrossOriginProtection()
if err := csrf.AddTrustedOrigin("https://seller.shop.example"); err != nil {
return nil, err
}
r.Use(csrf.Handler)
r.Mount("/api/orders", orders)
return r, nil
}
В go-chi/cors пустой AllowedOrigins означает ["*"], поэтому список задают всегда, а AllowOriginFunc, если он есть, отменяет список целиком. http.CrossOriginProtection (Go 1.25) отклоняет небезопасные межсайтовые запросы по Sec-Fetch-Site и Origin.
import { NestFactory } from "@nestjs/core";
import { NextFunction, Request, Response } from "express";
import { AppModule } from "./app.module";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ["https://shop.example", "https://seller.shop.example"],
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "X-Requested-With"],
});
app.use((req: Request, res: Response, next: NextFunction) => {
if (!SAFE_METHODS.has(req.method) && req.get("X-Requested-With") !== "fetch") {
res.status(403).json({ error: "csrf" });
return;
}
next();
});
await app.listen(3000);
}
bootstrap();
enableCors на Express передаёт настройки пакету cors. У него origin: true значит «reflect the request origin», то есть ровно отражение Origin; с credentials: true это дыра из списка выше. Свой заголовок работает как защита от CSRF только вместе с узким origin.
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://shop.example", "https://seller.shop.example"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "X-Requested-With"],
)
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
@app.middleware("http")
async def require_custom_header(request: Request, call_next):
if request.method not in SAFE_METHODS and request.headers.get("x-requested-with") != "fetch":
return JSONResponse({"error": "csrf"}, status_code=403)
return await call_next(request)
В CORSMiddleware при allow_credentials=True источники, методы и заголовки нельзя задавать как ['*']. allow_origin_regex документация советует писать без .* и .+, потому что они совпадают с /, @, # и ?.
Как проверить
- Тест на таблицу строк. Набор из примера выше (
//,/\,\\, табуляция,http:без слешей,javascript:, чужой суффикс) превращается в параметризованный тест функции редиректа. Любая новая «проверка» адреса возврата обязана его пройти. - curl с чужим Origin.
curl -si -H 'Origin: https://shop.example.evil' https://api.shop.example/api/ordersи то же сOrigin: null. В ответе не должно быть этого значения вAccess-Control-Allow-Origin, особенно рядом сAccess-Control-Allow-Credentials: true. - Изменяющий запрос без защиты. Отправить
POSTс cookie сессии, но без CSRF-токена или своего заголовка, и сContent-Type: text/plain: ожидается403, а не200. - В ревью. Любой
redirect/Location, куда попадает параметр запроса;origin: true,AllowOriginFunc, регулярки в CORS;csrf.disable()рядом с cookie-аутентификацией;GET, который меняет состояние;redirect_uriс префиксным сравнением.
Типичные ошибки
- Проверять адрес возврата через
startsWith("/"): пропускает//evil.exampleи/\evil.example. - Проверять «хост пустой или свой» по серверному парсеру:
javascript:иhttp:evil.exampleхоста не имеют. - Отключить CSRF, потому что «у нас REST и JSON», хотя сессия в cookie.
- Считать
SameSite=Laxполной защитой и держатьGET /api/orders/42/cancel. - Отражать
OriginсAccess-Control-Allow-Credentials: true, вноситьnullв список, сравнивать регуляркой без якорей. - Забыть
Vary: Originпри динамическомAccess-Control-Allow-Origin. - Считать, что CORS защищает API от запросов: он защищает только чтение ответа в браузере.
Глубже: почему preflight не защищает от CSRFрасширенноеспросят на собеседовании
Preflight OPTIONS браузер шлёт не всегда. Запросы GET, HEAD и POST с Content-Type из application/x-www-form-urlencoded, multipart/form-data или text/plain и только безопасными заголовками уходят сразу (MDN). Сервер получает их, выполняет и отвечает, а CORS решает лишь, отдать ли ответ скрипту. Отмена заказа к этому моменту уже случилась. Поэтому строгий CORS не заменяет CSRF-защиту, а защита «свой заголовок» работает именно потому, что превращает запрос в непростой и заставляет браузер спросить разрешения заранее.
Глубже: login CSRF и редирект после входарасширенное
Login CSRF выглядит безобидно: чужая страница отправляет форму входа с логином и паролем атакующего, и жертва оказывается в его аккаунте. Дальше покупатель сохраняет карту или адрес доставки, и всё это видит атакующий. Тот же справочник по CSRF описывает именно этот сценарий и требует защищать от CSRF и сам запрос входа. OWASP предлагает для этого пред-сессию с токеном в форме входа или свой заголовок в AJAX-запросе входа. Рядом с входом стоит и next=: страница входа обычно и есть то место, где живёт открытый редирект, поэтому обе проверки пишут вместе.
Коротко
- Открытый редирект (CWE-601) опасен доверенным доменом: фишинг и утечка кода OAuth2. Лучше не принимать адрес вовсе, а брать идентификатор цели; иначе только относительный путь без
//,\и управляющих символов. - Серверный парсер и браузер читают адрес по-разному, поэтому «хост пустой или свой» по разобранному адресу не проверка.
redirect_uriсравнивают с зарегистрированным точным совпадением строки (RFC 9700).- CSRF (CWE-352) есть там, где браузер сам прикладывает удостоверение: cookie, basic. Bearer в заголовке
Authorizationот него избавляет. - SameSite помогает, но не заменяет защиту:
LaxпропускаетGETверхнего уровня, а поддомен считается тем же сайтом. RFC 6265bis пока Internet-Draft. - Для своего SPA хватает своего заголовка при узком CORS; для форм нужен synchronizer token или подписанный double-submit.
- CORS (CWE-942) ломают отражением
Originс credentials,nullв списке и регулярками; чинят точным сравнением со списком иVary: Origin. - Preflight не защищает простые запросы: CORS решает о чтении ответа, а не о выполнении.
Что почитать дальше
- Браузер и сеть: CORS, cookie, XSS и CSRF - та же тема со стороны страницы.
- Хранение токенов на клиенте - HttpOnly cookie, refresh и что из этого следует для CSRF.
- Паттерны авторизации - где стоит каждая проверка доступа.
- Слои проверки и заголовки безопасности - короткий перечень дыр помимо доступа.
- Authorization Code Flow и PKCE - откуда берётся
redirect_uriи почему его сверяют точно.