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

Три уязвимости этой статьи живут на границе между сервером и браузером. Сервер отвечает «иди туда», «я выполнил запрос» или «этому сайту можно читать мой ответ», а браузер честно исполняет. Ошибка в каждом случае на сервере: он доверился адресу из запроса, 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 даёт порядок предпочтений:

  1. «Simply avoid using redirects and forwards». Часто после входа достаточно вести в личный кабинет.
  2. Не принимать адрес вообще: «have the user provide short name, ID or token which is mapped server-side to a full target URL». Для маркетплейса это ?next=order&id=42, а адрес собирает сервер.
  3. Если нужен путь возврата, принимать только относительный путь своего сайта, как safeRedirect выше: начинается с одного /, второй символ не /, нет \ и управляющих символов, у разобранного адреса нет схемы и хоста. Отказ ведёт на /, а не в ошибку 500.
  4. Если внешний адрес действительно нужен (переход к партнёру-перевозчику), сравнивать схему, хост и порт с явным списком: «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 в списке. По MDN Origin: 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 решает о чтении ответа, а не о выполнении.

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