Preflight request - 사전 요청

Preflight request란?

 

Mozilla의 Preflight request 페이지를 보면, 다음과 같이 정의하고 있다[1].

 

교차 출처 리소스 공유 사전 요청은 본격적인 교차 출처 HTTP 요청 전에 서버 측에서 그 요청의 메서드와 헤더에 대해 인식하고 있는지를 체크하는 것입니다.

 

또한, W3C의 Cross-Origin Resource Sharing 중, 6.2 Preflight Request 항목을 보면 다음과 같이 말하고 있다[2].

 

Preflight 요청에 대한 응답에서, 리소스가 처리하려는 메소드와 헤더, 자격증명의 지원 여부를 나타낸다.
(단순 메소드 및 헤더는 제외)

 

위 내용을 토대로 Preflight request 를 통해서 실제 요청 이전에 웹 브라우저가 확인하고자 하는 것은 다음과 같다.

 

  • 메소드 (Method)
  • 헤더 (Header)
  • 자격증명 (Credential)

즉, 요청에 포함되는 메소드 및 헤더가 아래의 항목 (단순 메소드/헤더 혹은 단순 요청) 중 하나일 경우를 제외하면, Preflight request를 실행하게 된다.

 

  • 단순 메소드 (simple method): 대/소문자를 구분하여, GET, HEAD, POST 중 하나인 경우
  • 단순 헤더 (simple header)
    1. 대/소문자 구분 없이, 헤더의 필드명이 다음 중 하나일 경우
      • Accept
      • Accept-Language
      • Content-Language
    2. 대/소문자 구분 없이, 헤더의 필드명이 Content-Type 이면서, 그 미디어 타입 값이 다음 중 하나일 경우
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain

* Mozilla 의 웹 페이지에서는 위의 단순 헤더/메소드와 아래의 항목을 포함하여, 단순 요청 (simple request)라고 정의하고 있으며, 단순 요청 이외에는 Preflight request를 실행한다 [3].

  • 유저 에이전트가 자동으로 설정한 헤더 외에, 수동으로 설정할 수 있는 헤더
    • DPR deprecated
    • Downlink experimental
    • Save-Data
    • Viewport-Width deprecated
    • Width deprecated

목적

 

Mozilla의 CORS 문서를 보면 아래와 같이 목적을 말하고 있다.

 

"preflighted" request는 위에서 논의한 “simple requests” 와는 달리, 먼저 OPTIONS 메서드를 통해 다른 도메인의 리소스로 HTTP 요청을 보내 실제 요청이 전송하기에 안전한지 확인합니다. Cross-site 요청은 유저 데이터에 영향을 줄 수 있기 때문에 이와같이 미리 전송(preflighted)합니다.

웹 브라우저가 Preflight request를 통해 안전을 확인하는 방법

 

위의 단순 요청(단순 헤더/메소드)를 제외한 요청이 발생 할 경우, 웹 브라우저는 다음의 순서로 안전을 확인한다.

 

  • 본 항목의 내용은 CORS (Cross-Origin-Request-Sharing)의 내용과 중첩되므로, 간단히 설명하며, 상세 프로세스는 이후에 작성할 CORS를 참조한다.
  1. OPTIONS 메소드를 통해 다음의 헤더를 사용하여, 요청을 발송
    1. Origin : Preflight request의 출처
    2. Access-Control-Request-Method : 실제 요청에서 사용할 메소드
    3. Access-Control-Request-Headers : 실제 요청에서 사용할 헤더
  2. 응답 패킷의 헤더에서 CORS 항목들을 체크

chromium 의 소스코드에서 CORS와 관련된 코드를 확인하던 중, CorsURLLoader class의 StartRequest 메소드에서 PreflightController의 PerformPreflightCheck 메소드를 실행하는 것을 확인했다.

// In file, "src/services/network/cors/cors_url_loader.cc"
void CorsURLLoader::StartRequest() {
  if (fetch_cors_flag_ && !skip_cors_enabled_scheme_check_ &&
      !base::Contains(url::GetCorsEnabledSchemes(), request_.url.scheme())) {
    HandleComplete(URLLoaderCompletionStatus(
        CorsErrorStatus(mojom::CorsError::kCorsDisabledScheme)));
    return;
  }
  
  // ~~~~~~~~~
  
  // Since we're doing a preflight, we won't reuse the original request. Cancel
  // it now to free up the socket.
  network_loader_.reset();

  has_authorization_covered_by_wildcard_ = false;
  preflight_controller_->PerformPreflightCheck(
      base::BindOnce(&CorsURLLoader::StartNetworkRequest,
                     weak_factory_.GetWeakPtr()),
      request_,
      PreflightController::WithTrustedHeaderClient(
          options_ & mojom::kURLLoadOptionUseHeaderClient),
      PreflightController::WithNonWildcardRequestHeadersSupport(false),
      tainted_, net::NetworkTrafficAnnotationTag(traffic_annotation_),
      network_loader_factory_, isolation_info_, std::move(devtools_observer));
}

 

// In file, "src/services/network/cors/preflight_controller.cc"
void PreflightController::PerformPreflightCheck(
    CompletionCallback callback,
    const ResourceRequest& request,
    WithTrustedHeaderClient with_trusted_header_client,
    WithNonWildcardRequestHeadersSupport
        with_non_wildcard_request_headers_support,
    bool tainted,
    const net::NetworkTrafficAnnotationTag& annotation_tag,
    mojom::URLLoaderFactory* loader_factory,
    const net::IsolationInfo& isolation_info,
    mojo::PendingRemote<mojom::DevToolsObserver> devtools_observer) {
  DCHECK(request.request_initiator);

  const net::NetworkIsolationKey& network_isolation_key =
      !isolation_info.IsEmpty()
          ? isolation_info.network_isolation_key()
          : request.trusted_params.has_value()
                ? request.trusted_params->isolation_info.network_isolation_key()
                : net::NetworkIsolationKey();
  if (!RetrieveCacheFlags(request.load_flags) && !request.is_external_request &&
      cache_.CheckIfRequestCanSkipPreflight(
        request.request_initiator.value(),
        request.url,
        network_isolation_key,
        request.credentials_mode,
        request.method,
        request.headers,
        request.is_revalidating
      )
  ) {
    std::move(callback).Run(net::OK, absl::nullopt, false);
    return;
  }

  auto emplaced_pair = loaders_.emplace(std::make_unique<PreflightLoader>(
      this, std::move(callback), request, with_trusted_header_client,
      with_non_wildcard_request_headers_support, tainted, annotation_tag,
      network_isolation_key, std::move(devtools_observer)));
  (*emplaced_pair.first)->Request(loader_factory);
}

void Request(mojom::URLLoaderFactory* loader_factory) {
    DCHECK(loader_);

    loader_->SetOnRedirectCallback(base::BindRepeating(
        &PreflightLoader::HandleRedirect, base::Unretained(this)));
    loader_->SetOnResponseStartedCallback(base::BindOnce(
        &PreflightLoader::HandleResponseHeader, base::Unretained(this)));

    loader_->DownloadToString(
        loader_factory,
        base::BindOnce(&PreflightLoader::HandleResponseBody,
                       base::Unretained(this)),
        0);
}
  
void HandleResponseHeader(const GURL& final_url,
                            const mojom::URLResponseHead& head) {
    if (devtools_observer_) {
      DCHECK(devtools_request_id_);
      devtools_observer_->OnCorsPreflightResponse(
          *devtools_request_id_, original_request_.url, head.Clone());
      devtools_observer_->OnCorsPreflightRequestCompleted(
          *devtools_request_id_, network::URLLoaderCompletionStatus(net::OK));
    }

    FinalizeLoader();

    absl::optional<CorsErrorStatus> detected_error_status;
    bool has_authorization_covered_by_wildcard = false;
    std::unique_ptr<PreflightResult> result = CreatePreflightResult(
        final_url, head, original_request_, tainted_, &detected_error_status);

    if (result) {
      // Preflight succeeded. Check |original_request_| with |result|.
      DCHECK(!detected_error_status);
      detected_error_status =
          CheckPreflightResult(result.get(), original_request_,
                               with_non_wildcard_request_headers_support_);
      has_authorization_covered_by_wildcard =
          result->HasAuthorizationCoveredByWildcard(original_request_.headers);
    }

    if (!(original_request_.load_flags & net::LOAD_DISABLE_CACHE) &&
        !detected_error_status) {
      controller_->AppendToCache(*original_request_.request_initiator,
                                 original_request_.url, network_isolation_key_,
                                 std::move(result));
    }

    std::move(completion_callback_)
        .Run(detected_error_status ? net::ERR_FAILED : net::OK,
             detected_error_status, has_authorization_covered_by_wildcard);

    RemoveFromController();
    // |this| is deleted here.
}

 

CheckPreflightResult 메소드를 보면, EnsureAllowedCrossOriginMethod 메소드와 EnsureAllowedCrossOriginHeaders 메소드를 통해 Preflight 결과를 확인하는 것을 알 수 있다.

 

// in file, "src/services/network/cors/preflight_controller.cc"
absl::optional<CorsErrorStatus> CheckPreflightResult(
    PreflightResult* result,
    const ResourceRequest& original_request,
    PreflightResult::WithNonWildcardRequestHeadersSupport
        with_non_wildcard_request_headers_support) {
  absl::optional<CorsErrorStatus> status =
      result->EnsureAllowedCrossOriginMethod(original_request.method);
  if (status)
    return status;

  return result->EnsureAllowedCrossOriginHeaders(
      original_request.headers, original_request.is_revalidating,
      with_non_wildcard_request_headers_support);
}

 

우선, EnsureAllowedCrossOriginMethod() 함수를 통해서, 원본 요청이 Access-Control-Request-Method 배열인 methods_ 에 포함되는지 확인한다. 만약 포함되지 않는다면, SafelistedMethod를 통해 안전한 메소드인지 확인한다. 해당 리스트는, IsCorsSafeListedMethod 메소드에서 확인할 수 있으며, GET, HEAD, POST 등의 경우 true 값을 return 한다.

또한, 같은 맥락으로 EnsureAllowedCrossOriginHeaders() 함수를 통해서, 헤더에 대해서도 확인을 하는 과정을 거친다.

* 두 경우 모두, 와일드카드 "*" 에 대해서도 고려되는 것을 로직을 통해 알 수 있다.

 

// In file, "src/services/network/cors/preflight_result.cc
absl::optional<CorsErrorStatus> PreflightResult::EnsureAllowedCrossOriginMethod(
    const std::string& method) const {
  // Request method is normalized to upper case, and comparison is performed in
  // case-sensitive way, that means access control header should provide an
  // upper case method list.
  const std::string normalized_method = base::ToUpperASCII(method);
  if (methods_.find(normalized_method) != methods_.end() ||
      IsCorsSafelistedMethod(normalized_method)) {
    return absl::nullopt;
  }

  if (!credentials_ && methods_.find("*") != methods_.end())
    return absl::nullopt;

  return CorsErrorStatus(mojom::CorsError::kMethodDisallowedByPreflightResponse,
                         method);
}
// In file, "src/services/network/public/cpp/cors/cors.cc"
bool IsCorsSafelistedMethod(const std::string& method) {
  // https://fetch.spec.whatwg.org/#cors-safelisted-method
  // "A CORS-safelisted method is a method that is `GET`, `HEAD`, or `POST`."
  std::string method_upper = base::ToUpperASCII(method);
  return method_upper == net::HttpRequestHeaders::kGetMethod ||
         method_upper == net::HttpRequestHeaders::kHeadMethod ||
         method_upper == net::HttpRequestHeaders::kPostMethod;
}
// In file, "src/services/network/cors/preflight_result.cc"
absl::optional<CorsErrorStatus>
PreflightResult::EnsureAllowedCrossOriginHeaders(
    const net::HttpRequestHeaders& headers,
    bool is_revalidating,
    WithNonWildcardRequestHeadersSupport
        with_non_wildcard_request_headers_support) const {
  const bool has_wildcard = !credentials_ && headers_.contains("*");
  if (has_wildcard) {
    if (with_non_wildcard_request_headers_support) {
      // "authorization" is the only member of
      // https://fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name.
      if (headers.HasHeader(kAuthorization) &&
          !headers_.contains(kAuthorization)) {
        CorsErrorStatus error_status(
            mojom::CorsError::kHeaderDisallowedByPreflightResponse,
            kAuthorization);
        error_status.has_authorization_covered_by_wildcard_on_preflight = true;
        return error_status;
      }
    }
    return absl::nullopt;
  }

  // Forbidden headers are forbidden to be used by JavaScript, and checked
  // beforehand. But user-agents may add these headers internally, and it's
  // fine.
  for (const auto& name : CorsUnsafeNotForbiddenRequestHeaderNames(
           headers.GetHeaderVector(), is_revalidating)) {
    // Header list check is performed in case-insensitive way. Here, we have a
    // parsed header list set in lower case, and search each header in lower
    // case.
    if (!headers_.contains(name)) {
      return CorsErrorStatus(
          mojom::CorsError::kHeaderDisallowedByPreflightResponse, name);
    }
  }
  return absl::nullopt;
}
absl::optional<CorsErrorStatus>
PreflightResult::EnsureAllowedCrossOriginHeaders(
    const net::HttpRequestHeaders& headers,
    bool is_revalidating,
    WithNonWildcardRequestHeadersSupport
        with_non_wildcard_request_headers_support) const {
  const bool has_wildcard = !credentials_ && headers_.contains("*");
  if (has_wildcard) {
    if (with_non_wildcard_request_headers_support) {
      // "authorization" is the only member of
      // https://fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name.
      if (headers.HasHeader(kAuthorization) &&
          !headers_.contains(kAuthorization)) {
        CorsErrorStatus error_status(
            mojom::CorsError::kHeaderDisallowedByPreflightResponse,
            kAuthorization);
        error_status.has_authorization_covered_by_wildcard_on_preflight = true;
        return error_status;
      }
    }
    return absl::nullopt;
  }

  // Forbidden headers are forbidden to be used by JavaScript, and checked
  // beforehand. But user-agents may add these headers internally, and it's
  // fine.
  for (const auto& name : CorsUnsafeNotForbiddenRequestHeaderNames(
           headers.GetHeaderVector(), is_revalidating)) {
    // Header list check is performed in case-insensitive way. Here, we have a
    // parsed header list set in lower case, and search each header in lower
    // case.
    if (!headers_.contains(name)) {
      return CorsErrorStatus(
          mojom::CorsError::kHeaderDisallowedByPreflightResponse, name);
    }
  }
  return absl::nullopt;
}

 

아래의 내용을 보면, 위에 언급한 단순 헤더(Simple header) 외에도, 몇 가지 추가 헤더들이 safe header로 취급되는 것을 알 수 있다.

 

/// In file, "src/services/network/public/cpp/cors/cors.cc"
std::vector<std::string> CorsUnsafeNotForbiddenRequestHeaderNames(
    const net::HttpRequestHeaders::HeaderVector& headers,
    bool is_revalidating) {
  std::vector<std::string> header_names;
  std::vector<std::string> potentially_unsafe_names;

  constexpr size_t kSafeListValueSizeMax = 1024;
  size_t safe_list_value_size = 0;

  for (const auto& header : headers) {
    if (!net::HttpUtil::IsSafeHeader(header.key))
      continue;

    const std::string name = base::ToLowerASCII(header.key);

    if (is_revalidating) {
      if (name == "if-modified-since" || name == "if-none-match" ||
          name == "cache-control") {
        continue;
      }
    }
    if (!IsCorsSafelistedHeader(name, header.value)) {
      header_names.push_back(name);
    } else {
      potentially_unsafe_names.push_back(name);
      safe_list_value_size += header.value.size();
    }
  }
  if (safe_list_value_size > kSafeListValueSizeMax) {
    header_names.insert(header_names.end(), potentially_unsafe_names.begin(),
                        potentially_unsafe_names.end());
  }
  return header_names;
}

bool IsCorsSafelistedHeader(const std::string& name, const std::string& value) {
  const std::string lower_name = base::ToLowerASCII(name);

  // If |value|’s length is greater than 128, then return false.
  if (value.size() > 128)
    return false;

  // https://fetch.spec.whatwg.org/#cors-safelisted-request-header
  // "A CORS-safelisted header is a header whose name is either one of `Accept`,
  // `Accept-Language`, and `Content-Language`, or whose name is
  // `Content-Type` and value, once parsed, is one of
  //     `application/x-www-form-urlencoded`, `multipart/form-data`, and
  //     `text/plain`
  // or whose name is a byte-case-insensitive match for one of
  //      `DPR`, `Save-Data`, `device-memory`, `Viewport-Width`, and `Width`,
  // and whose value, once extracted, is not failure."
  //
  // Treat inspector headers as a CORS-safelisted headers, since they are added
  // by blink when the inspector is open.
  //
  // Treat 'Intervention' as a CORS-safelisted header, since it is added by
  // Chrome when an intervention is (or may be) applied.
  static const char* const safe_names[] = {
      "accept",
      "accept-language",
      "content-language",
      "intervention",
      "content-type",
      "save-data",
      // The Device Memory header field is a number that indicates the client’s
      // device memory i.e. approximate amount of ram in GiB. The header value
      // must satisfy ABNF  1*DIGIT [ "." 1*DIGIT ]
      // See
      // https://w3c.github.io/device-memory/#sec-device-memory-client-hint-header
      // for more details.
      "device-memory",
      "dpr",
      "width",
      "viewport-width",

      // The `Sec-CH-Lang` header field is a proposed replacement for
      // `Accept-Language`, using the Client Hints infrastructure.
      //
      // https://tools.ietf.org/html/draft-west-lang-client-hint
      "sec-ch-lang",

      // The `Sec-CH-UA-*` header fields are proposed replacements for
      // `User-Agent`, using the Client Hints infrastructure.
      //
      // https://tools.ietf.org/html/draft-west-ua-client-hints
      "sec-ch-ua",
      "sec-ch-ua-platform",
      "sec-ch-ua-arch",
      "sec-ch-ua-model",
      "sec-ch-ua-mobile",
      "sec-ch-ua-full-version",
      "sec-ch-ua-platform-version",

      // The `Sec-CH-Prefers-Color-Scheme` header field is modeled after the
      // prefers-color-scheme user preference media feature. It reflects the
      // user’s desire that the page use a light or dark color theme. This is
      // currently pull from operating system preferences, although there may be
      // internal UI in the future.
      //
      // https://wicg.github.io/user-preference-media-features-headers/#sec-ch-prefers-color-scheme
      "sec-ch-prefers-color-scheme",
      "sec-ch-ua-bitness",
  };
  if (std::find(std::begin(safe_names), std::end(safe_names), lower_name) ==
      std::end(safe_names))
    return false;

  // Client hints are device specific, and not origin specific. As such all
  // client hint headers are considered as safe.
  // See
  // third_party/blink/public/mojom/web_client_hints/web_client_hints_types.mojom.
  // Client hint headers can be added by Chrome automatically or via JavaScript.
  if (lower_name == "device-memory" || lower_name == "dpr")
    return IsSimilarToDoubleABNF(value);
  if (lower_name == "width" || lower_name == "viewport-width")
    return IsSimilarToIntABNF(value);
  const std::string lower_value = base::ToLowerASCII(value);
  if (lower_name == "save-data")
    return lower_value == "on";

  if (lower_name == "accept") {
    return !std::any_of(value.begin(), value.end(),
                        IsCorsUnsafeRequestHeaderByte);
  }

  if (lower_name == "accept-language" || lower_name == "content-language") {
    return std::all_of(value.begin(), value.end(), [](char c) {
      return (0x30 <= c && c <= 0x39) || (0x41 <= c && c <= 0x5a) ||
             (0x61 <= c && c <= 0x7a) || c == 0x20 || c == 0x2a || c == 0x2c ||
             c == 0x2d || c == 0x2e || c == 0x3b || c == 0x3d;
    });
  }

  if (lower_name == "content-type")
    return IsCorsSafelistedLowerCaseContentType(lower_value);

  return true;
}

참고자료

 

[1] 사전 요청 - 용어 사전 | MDN (mozilla.org)

 

사전 요청 - 용어 사전 | MDN

교차 출처 리소스 공유 사전 요청은 본격적인 교차 출처 HTTP 요청 전에 서버 측에서 그 요청의 메서드와 헤더에 대해 인식하고 있는지를 체크하는 것입니다.

developer.mozilla.org

[2] Cross-Origin Resource Sharing (w3.org) - 6.2 Preflight Request

 

Cross-Origin Resource Sharing

Abstract This document defines a mechanism to enable client-side cross-origin requests. Specifications that enable an API to make cross-origin requests to resources can use the algorithms defined by this specification. If such an API is used on http://exam

www.w3.org

[3] 교차 출처 리소스 공유 (CORS) - HTTP | MDN (mozilla.org)

 

교차 출처 리소스 공유 (CORS) - HTTP | MDN

교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라

developer.mozilla.org

[4] The Chromium Projects

 

The Chromium Projects

Home of the Chromium Open Source Project

www.chromium.org

 

Comment