О CORS

2018-12-08

Современные браузеры не хотят просто так ходить на другие домены. Точнее на другой Origin. Это касается JavaScript, выполняемого в браузере.

Нельзя просто так взять, и потыкать API, находящееся в другом домене. И нельзя сделать crawler, работающий в браузере. Это будут cross-origin запросы. И они запрещены. Из соображений безопасности. Похоже, это скорее забота о том, чтобы конфиденциальные данные из вашего браузера не утекли куда не надо, чем попытка затруднить жизнь тем, кто захочет воспользоваться вашим API.

Но иногда у вас какой-нибудь SPA на React должен ходить в API на другой домен. И тут на помощь приходит CORS. Cross-Origin Resource Sharing. Сервер другого ресурса, нашего API, может явно сказать, с каких Origin к нему разрешён доступ. С помощью некоторых дополнительных HTTP заголовков.

В простейшем случае, когда наш JavaScript делает GET запрос, и не передаёт никаких потенциально опасных заголовков, браузер включает в запрос заголовок Origin. А сервер должен ответить с заголовком Access-Control-Allow-Origin, где, собственно, и говорит, разрешён ли доступ с этого Origin.

CORS Simple Request

В более сложном случае, например, когда мы делаем POST запрос с JSON в теле запроса, браузер делает так называемый "preflight request".

Это OPTIONS запрос, в который включены заголовки, описывающие будущий полноценный запрос. Access-Control-Request-Method говорит, какой HTTP метод будет в будущем запросе. Access-Control-Request-Headers говорит, какие HTTP заголовки будут в будущем запросе (помимо тех, вроде Host, User-Agent и Acept, которые и так разрешены по умолчанию). Конечно же, этот запрос включает заголовок Origin.

Если сервер согласен и с Origin, и с методом, и с заголовками, он отвечает 200 OK с дополнительными заголовками. Снова заголовок Access-Control-Allow-Origin выражает согласие сервера с Origin. Заголовок Access-Control-Allow-Methods перечисляет методы, которые сервер готов принимать с этого Origin. Заголовок Access-Control-Allow-Headers подтверждает готовность сервера принимать заголовки. Заголовок Access-Control-Max-Age указывает срок действия данного соглашения, браузер может какое-то время не повторять preflight запросы.

CORS Prefligh Request

В Access-Control-Allow-Origin сервер может вернуть не только тот самый Origin, что запросил доступ, но и специальное значение "*", звёздочку. Это означает "любой Origin". Но в этом случае запрещается передавать в запросе credentials. Под "credentials" подразумеваются куки, информация об аутентификации, включая заголовки и TLS сертификаты. Всё то, что браузер может неявно подмешать в запрос, и что явно идентифицирует пользователя.

По умолчанию credentials запрещены. Но сервер может разрешить, если выдаст заголовок Access-Control-Allow-Credentials со значением true. Но для Origin "*" это не работает.

Если говорить про fetch() в браузере, то для credentials есть свои опции.

fetch(url, {
    method: "POST", // *GET, POST, PUT, DELETE, etc.
    mode: "cors", // no-cors, cors, *same-origin
    cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
    credentials: "same-origin", // include, *same-origin, omit
    headers: {
        "Content-Type": "application/json; charset=utf-8",
        // "Content-Type": "application/x-www-form-urlencoded",
    },
    redirect: "follow", // manual, *follow, error
    referrer: "no-referrer", // no-referrer, *client
    body: JSON.stringify(data), // body data type must match "Content-Type" header
})

Сервер может ещё использовать заголовок Vary, чтобы указать на заголовки запроса, изменение которых приведёт к другому ответу сервера. Поэтому положено, если Access-Control-Allow-Origin возвращает конкретный Origin, то должен быть ещё и Vary: Origin.

Почти во всех случаях, когда у вас есть SPA и отдельное API, вам придётся учитывать CORS.

Вам повезло, если вы можете закрыть и фронтенд и бэкенд общим прокси на одном домене. Тогда ваше API будет, скажем, просто по относительному URL "/api", и это будет same-origin, и CORS не понадобится.

Вам повезло, если перед бэкендом вы можете поставить прокси, которое сможет добавлять заголовки. Например, Nginx. Тогда он сможет взять на себя всю работу c CORS. Не забудьте включить в Access-Control-Allow-Headers все заголовки, которые рожает ваше SPA.

location /api {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '*';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Origin,Accept,Key,Keep-Alive,User-Agent,If-Modified-Since,Cache-Control,Content-Type,Authorization';
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
    }
    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Origin,Accept,Key,Keep-Alive,User-Agent,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
    proxy_pass http://127.0.0.1:8080/;
}

Если вам позарез нужны credentials, например, аутентификация у вас через cookie, то всё немного усложняется.

location /api {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '$http_origin';
        add_header 'Vary' 'Origin';
        add_header 'Access-Control-Allow-Credentials' 'true';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Origin,Accept,Key,Keep-Alive,User-Agent,If-Modified-Since,Cache-Control,Content-Type,Authorization';
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
    }
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Vary' 'Origin';
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Origin,Accept,Key,Keep-Alive,User-Agent,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
    proxy_pass http://127.0.0.1:8080/api;
}

Как правило, на preflight запрос сервер должен отвечать минуя все механизмы авторизации. Собственно, так и произойдёт, если вы к этому location добавите ещё, например, директивы auth_basic. Потому что return прерывает обработку запроса раньше.

Приведённые конфигурации открывают доступ к API с любого Origin. Возможно, это не то, что вы хотите. Тогда разбираться с CORS придётся в самом бэкенде.

Если вы разворачиваетесь в облаке, то вряд ли вам захочется запускать свой Nginx, лучше воспользоваться облачным load balancer. А они вовсе не обязаны втыкать дополнительные заголовки. К тому же в облаках статику и динамику как-то удобнее размещать в разных доменах, CDN, всё такое. Так что от CORS не отвертитесь, и снова на бэкенде.

В Spring Boot можно сделать так:

@Configuration
@EnableConfigurationProperties(CorsProperties::class)
open class CorsConfiguration {

    @Bean
    open fun corsConfigurer(
        corsProperties: CorsProperties?
    ): WebMvcConfigurer {
        return object : WebMvcConfigurer {
            override fun addCorsMappings(registry: CorsRegistry) {
                if (corsProperties != null && corsProperties.allowedOrigins.isNotEmpty()) {
                    registry.addMapping("/api/**").allowedOrigins(*corsProperties.allowedOrigins.toTypedArray())
                }
            }
        }
    }

}

@ConfigurationProperties(prefix = "web.cors")
open class CorsProperties {
    var allowedOrigins: List<String> = mutableListOf()
}

Тогда разрешить нужные Origin можно в application.yml:

web:
  cors:
    allowed-origins:
      - 'http://localhost:3000'
      - 'http://my.webapp.com'