← Курс/Что такое CORS и как с ним работать?#132 из 257+40 XP

Что такое CORS и как с ним работать?

Краткий ответ

CORS (Cross-Origin Resource Sharing) — механизм безопасности браузера, основанный на Same-Origin Policy (SOP). SOP запрещает странице делать запросы к другому "origin" (комбинации протокол+домен+порт). CORS позволяет серверу явно разрешить запросы с определённых чужих origins через специальные HTTP-заголовки. Важно: CORS ограничивает только браузер — серверные запросы (Node.js, curl) SOP не затрагивает.

Полный разбор

Same-Origin Policy (Политика одного источника)

"Origin" — это комбинация из трёх частей:

https://example.com:443/path

└─── протокол ───┘ └── домен ──┘ └─ порт ─┘
      https          example.com    443

Всё вместе = один Origin

Правило: браузер разрешает скрипту делать запросы только к **тому же origin**:

Страница: https://myapp.com

РАЗРЕШЕНО:
  https://myapp.com/api/users    ← тот же origin
  https://myapp.com/data.json   ← тот же origin

ЗАБЛОКИРОВАНО браузером:
  https://api.other.com/users   ← другой домен
  http://myapp.com/api          ← другой протокол (http vs https)
  https://myapp.com:8080/api    ← другой порт

Как CORS решает проблему

Сервер добавляет заголовки, которые говорят браузеру: "я разрешаю запросы от этих origins":

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

Если заголовок есть и origin совпадает — браузер разрешает доступ к ответу.

Простые и непростые запросы

**Простые запросы** (simple requests) — браузер делает сразу без preflight:

Методы: GET, POST, HEAD
Заголовки: только стандартные (Content-Type: text/plain, application/x-www-form-urlencoded, multipart/form-data)

**Непростые запросы** — перед основным запросом браузер делает **preflight** (предзапрос):

Триггеры для preflight:
  - Методы: PUT, DELETE, PATCH
  - Заголовки: Authorization, Content-Type: application/json, кастомные
  - Тело запроса в JSON

Preflight запрос (OPTIONS)

Браузер → Сервер:
  OPTIONS /api/users HTTP/1.1
  Origin: https://myapp.com
  Access-Control-Request-Method: DELETE
  Access-Control-Request-Headers: Authorization, Content-Type

Сервер → Браузер:
  HTTP/1.1 204 No Content
  Access-Control-Allow-Origin: https://myapp.com
  Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
  Access-Control-Allow-Headers: Authorization, Content-Type
  Access-Control-Max-Age: 86400  ← кешировать preflight 24 часа

Браузер → Сервер (основной запрос):
  DELETE /api/users/42 HTTP/1.1
  Origin: https://myapp.com
  Authorization: Bearer token123

Credentials (куки и авторизация)

По умолчанию CORS запросы не включают куки и заголовки авторизации. Для их включения:

// На клиенте
fetch('https://api.example.com/data', {
  credentials: 'include'  // отправлять куки
})

// На сервере (нельзя использовать * с credentials!)
Access-Control-Allow-Origin: https://myapp.com  // конкретный origin, не *
Access-Control-Allow-Credentials: true

Решения CORS-проблем

1. Правильная настройка сервера (лучший вариант)

// Express.js пример
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com')
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')

  if (req.method === 'OPTIONS') {
    res.status(204).end()
    return
  }
  next()
})

**2. Прокси-сервер** — браузер → твой сервер → чужой API (CORS не применяется к серверным запросам):

Браузер → myapp.com/api/proxy → api.other.com
          ↑ тот же origin       ↑ серверный запрос, SOP не работает

**3. JSONP** (устаревший) — только GET, использует <script src> который не подпадает под SOP.

Что НЕ обходит CORS в браузере

// CORS нельзя обойти из браузера:
// - Изменить заголовки запроса чтобы "притвориться" другим origin
// - Читать ответ без разрешения сервера
// - Использовать fetch с другим Origin заголовком

// Это работает только не в браузере (curl, Node.js, Postman):
// curl -H "Origin: https://evil.com" https://api.example.com/data
// ← сервер ответит, но браузер бы заблокировал

CORS — защита **браузера** от атак типа CSRF, где злой сайт пытается сделать запрос к твоему банку от имени твоего браузера.

CORS заголовки — шпаргалка

Access-Control-Allow-Origin        — разрешённые origins (* или конкретный)
Access-Control-Allow-Methods       — разрешённые HTTP методы
Access-Control-Allow-Headers       — разрешённые заголовки запроса
Access-Control-Expose-Headers      — заголовки ответа, доступные JS
Access-Control-Allow-Credentials   — разрешить куки/авторизацию
Access-Control-Max-Age             — время кеширования preflight (секунды)

Связанные уроки курса

  • Fetch API
  • CORS
  • Как отвечать на собеседовании

    **Начни с SOP**: "CORS существует из-за Same-Origin Policy — браузер блокирует запросы к другому origin в целях безопасности."

    **Объясни механизм**: "CORS позволяет серверу указать, каким origins он доверяет, через специальные заголовки ответа."

    **Упомяни preflight**: "Для непростых запросов (DELETE, JSON-тело, кастомные заголовки) браузер сначала отправляет OPTIONS preflight-запрос."

    **Скажи как решать**: "Правильное решение — настроить CORS-заголовки на сервере. Прокси — альтернатива когда нет доступа к серверу."

    **Подчеркни безопасность**: "CORS нельзя обойти из браузера — это защита от CSRF. Серверные запросы SOP не затрагивает."

    **Время ответа**: 3-4 минуты.

    Красные флаги ответа

    1. **"CORS — это ошибка которую надо отключить"** или **"просто добавь Access-Control-Allow-Origin: *"** — это игнорирование модели безопасности. Wildcard * нельзя использовать с credentials и это потенциально небезопасно.

    2. **"CORS обходится на клиенте"** — нет. CORS — защита браузера. Обойти можно только через прокси на своём сервере, но не из браузерного JS.

    3. **Не знать о preflight** — если не понимаешь почему DELETE или POST с JSON вызывает дополнительный OPTIONS-запрос — ты не понимаешь как CORS работает на практике.

    Примеры

    Демонстрация простых vs непростых запросов, логика preflight и правильная диагностика CORS-ошибок

    // ===== ЧТО ДЕЛАЕТ ЗАПРОС "НЕПРОСТЫМ" (требует preflight) =====
    console.log('=== Simple vs Non-Simple Requests ===')
    
    function analyzeRequest(config) {
      const { method, headers = {}, body } = config
      const simpleMethods = ['GET', 'POST', 'HEAD']
      const simpleContentTypes = [
        'application/x-www-form-urlencoded',
        'multipart/form-data',
        'text/plain'
      ]
      const simpleHeaders = [
        'accept', 'accept-language', 'content-language', 'content-type'
      ]
    
      const issues = []
    
      // Проверяем метод
      if (!simpleMethods.includes(method.toUpperCase())) {
        issues.push(`Метод ${method} не простой (только GET, POST, HEAD)`)
      }
    
      // Проверяем Content-Type
      const ct = headers['Content-Type'] || headers['content-type'] || ''
      if (ct && !simpleContentTypes.some(t => ct.toLowerCase().startsWith(t))) {
        issues.push(`Content-Type "${ct}" не простой`)
      }
    
      // Проверяем кастомные заголовки
      const customHeaders = Object.keys(headers).filter(
        h => !simpleHeaders.includes(h.toLowerCase()) && h.toLowerCase() !== 'content-type'
      )
      if (customHeaders.length > 0) {
        issues.push(`Кастомные заголовки: ${customHeaders.join(', ')}`)
      }
    
      const needsPreflight = issues.length > 0
      return { needsPreflight, reasons: issues }
    }
    
    const requests = [
      { label: 'Простой GET', config: { method: 'GET' } },
      { label: 'Простой POST (form)', config: { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } },
      { label: 'POST с JSON', config: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' } },
      { label: 'POST с Authorization', config: { method: 'POST', headers: { 'Authorization': 'Bearer token' } } },
      { label: 'DELETE запрос', config: { method: 'DELETE' } },
      { label: 'PUT с JSON и Auth', config: { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer token' } } },
    ]
    
    for (const { label, config } of requests) {
      const result = analyzeRequest(config)
      const status = result.needsPreflight ? '⚡ PREFLIGHT нужен' : 'OK простой'
      console.log(`\n${label}: ${status}`)
      if (result.reasons.length > 0) {
        result.reasons.forEach(r => console.log(`   - ${r}`))
      }
    }
    
    // ===== СИМУЛЯЦИЯ PREFLIGHT ПОТОКА =====
    console.log('\n=== Симуляция Preflight потока ===')
    
    function simulateCorsFlow(clientOrigin, request, serverConfig) {
      console.log(`\nКлиент: ${clientOrigin}`)
      console.log(`Запрос: ${request.method} ${request.url}`)
    
      const { needsPreflight } = analyzeRequest(request)
    
      if (needsPreflight) {
        console.log('\n→ Браузер отправляет OPTIONS preflight:')
        console.log(`  OPTIONS ${request.url}`)
        console.log(`  Origin: ${clientOrigin}`)
        console.log(`  Access-Control-Request-Method: ${request.method}`)
        if (request.headers) {
          console.log(`  Access-Control-Request-Headers: ${Object.keys(request.headers).join(', ')}`)
        }
    
        const allowed = serverConfig.allowedOrigins.includes(clientOrigin) ||
                        serverConfig.allowedOrigins.includes('*')
        const methodAllowed = serverConfig.allowedMethods.includes(request.method)
    
        if (allowed && methodAllowed) {
          console.log('\n← Сервер отвечает на preflight:')
          console.log(`  HTTP/1.1 204 No Content`)
          console.log(`  Access-Control-Allow-Origin: ${clientOrigin}`)
          console.log(`  Access-Control-Allow-Methods: ${serverConfig.allowedMethods.join(', ')}`)
          console.log(`  Access-Control-Allow-Headers: ${serverConfig.allowedHeaders.join(', ')}`)
          console.log(`  Access-Control-Max-Age: 86400`)
          console.log('\n→ Preflight прошёл! Отправляем основной запрос...')
          console.log('← Основной запрос выполнен успешно')
        } else {
          console.log(`\n← Сервер отвечает на preflight: заблокировано!`)
          if (!allowed) console.log(`   Origin ${clientOrigin} не разрешён`)
          if (!methodAllowed) console.log(`   Метод ${request.method} не разрешён`)
          console.log('← Браузер блокирует основной запрос → CORS Error')
        }
      } else {
        console.log('→ Простой запрос — без preflight')
        const allowed = serverConfig.allowedOrigins.includes(clientOrigin) ||
                        serverConfig.allowedOrigins.includes('*')
        console.log(allowed
          ? '← Сервер разрешает: Access-Control-Allow-Origin присутствует'
          : '← Сервер не отправил CORS заголовки → браузер блокирует')
      }
    }
    
    const serverConfig = {
      allowedOrigins: ['https://myapp.com', 'https://staging.myapp.com'],
      allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
      allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID']
    }
    
    simulateCorsFlow('https://myapp.com', {
      method: 'DELETE',
      url: 'https://api.backend.com/users/42',
      headers: { 'Authorization': 'Bearer token' }
    }, serverConfig)
    
    simulateCorsFlow('https://evil.com', {
      method: 'POST',
      url: 'https://api.backend.com/users',
      headers: { 'Content-Type': 'application/json' }
    }, serverConfig)