blob: 20b537c63e6ac3cc795f9fe3ba72c8197ae95811 [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "services/network/public/cpp/cors/cors.h"
#include <algorithm>
#include <cctype>
#include <set>
#include <vector>
#include "base/containers/flat_set.h"
#include "base/metrics/histogram_macros.h"
#include "base/no_destructor.h"
#include "base/strings/string_piece.h"
#include "base/strings/string_util.h"
#include "net/base/mime_util.h"
#include "net/http/http_request_headers.h"
#include "net/http/http_util.h"
#include "services/network/public/cpp/is_potentially_trustworthy.h"
#include "services/network/public/cpp/request_mode.h"
#include "url/gurl.h"
#include "url/origin.h"
#include "url/url_constants.h"
#include "url/url_util.h"
// String conversion from blink::String to std::string for header name/value
// should be latin-1, not utf-8, as per HTTP. Note that as we use ByteString
// as the IDL types of header name/value, a character whose code point is
// greater than 255 has already been blocked.
namespace network {
namespace {
const char kAsterisk[] = "*";
const char kLowerCaseTrue[] = "true";
// TODO(toyoshim): Consider to move the following method to
// //net/base/mime_util, and expose to Blink platform/network in order to
// replace the existing equivalent method in HTTPParser.
// We may prefer to implement a strict RFC2616 media-type
// (https://tools.ietf.org/html/rfc2616#section-3.7) parser.
std::string ExtractMIMETypeFromMediaType(const std::string& media_type) {
std::string::size_type semicolon = media_type.find(';');
std::string top_level_type;
std::string subtype;
if (net::ParseMimeTypeWithoutParameter(media_type.substr(0, semicolon),
&top_level_type, &subtype)) {
return top_level_type + "/" + subtype;
}
return std::string();
}
// Returns true only if |header_value| satisfies ABNF: 1*DIGIT [ "." 1*DIGIT ]
bool IsSimilarToDoubleABNF(const std::string& header_value) {
if (header_value.empty())
return false;
char first_char = header_value.at(0);
if (!isdigit(first_char))
return false;
bool period_found = false;
bool digit_found_after_period = false;
for (char ch : header_value) {
if (isdigit(ch)) {
if (period_found) {
digit_found_after_period = true;
}
continue;
}
if (ch == '.') {
if (period_found)
return false;
period_found = true;
continue;
}
return false;
}
if (period_found)
return digit_found_after_period;
return true;
}
// Returns true only if |header_value| satisfies ABNF: 1*DIGIT
bool IsSimilarToIntABNF(const std::string& header_value) {
if (header_value.empty())
return false;
for (char ch : header_value) {
if (!isdigit(ch))
return false;
}
return true;
}
// https://fetch.spec.whatwg.org/#cors-unsafe-request-header-byte
bool IsCorsUnsafeRequestHeaderByte(char c) {
const auto u = static_cast<uint8_t>(c);
return (u < 0x20 && u != 0x09) || u == 0x22 || u == 0x28 || u == 0x29 ||
u == 0x3a || u == 0x3c || u == 0x3e || u == 0x3f || u == 0x40 ||
u == 0x5b || u == 0x5c || u == 0x5d || u == 0x7b || u == 0x7d ||
u == 0x7f;
}
// |value| should be lower case.
bool IsCorsSafelistedLowerCaseContentType(const std::string& value) {
DCHECK_EQ(value, base::ToLowerASCII(value));
if (std::any_of(value.begin(), value.end(), IsCorsUnsafeRequestHeaderByte))
return false;
std::string mime_type = ExtractMIMETypeFromMediaType(value);
return mime_type == "application/x-www-form-urlencoded" ||
mime_type == "multipart/form-data" || mime_type == "text/plain";
}
bool IsNoCorsSafelistedHeaderNameLowerCase(const std::string& lower_name) {
if (lower_name != "accept" && lower_name != "accept-language" &&
lower_name != "content-language" && lower_name != "content-type") {
return false;
}
return true;
}
base::Optional<CorsErrorStatus> CheckAccessInternal(
const GURL& response_url,
const base::Optional<std::string>& allow_origin_header,
const base::Optional<std::string>& allow_credentials_header,
mojom::CredentialsMode credentials_mode,
const url::Origin& origin) {
if (allow_origin_header == kAsterisk) {
// A wildcard Access-Control-Allow-Origin can not be used if credentials are
// to be sent, even with Access-Control-Allow-Credentials set to true.
// See https://fetch.spec.whatwg.org/#cors-protocol-and-credentials.
if (credentials_mode != mojom::CredentialsMode::kInclude)
return base::nullopt;
// Since the credential is a concept for network schemes, we perform the
// wildcard check only for HTTP and HTTPS. This is a quick hack to allow
// data URL (see https://crbug.com/315152).
// TODO(https://crbug.com/736308): Once the callers exist only in the
// browser process or network service, this check won't be needed any more
// because it is always for network requests there.
if (response_url.SchemeIsHTTPOrHTTPS())
return CorsErrorStatus(mojom::CorsError::kWildcardOriginNotAllowed);
} else if (!allow_origin_header) {
return CorsErrorStatus(mojom::CorsError::kMissingAllowOriginHeader);
} else if (*allow_origin_header != origin.Serialize()) {
// We do not use url::Origin::IsSameOriginWith() here for two reasons below.
// 1. Allow "null" to match here. The latest spec does not have a clear
// information about this (https://fetch.spec.whatwg.org/#cors-check),
// but the old spec explicitly says that "null" works here
// (https://www.w3.org/TR/cors/#resource-sharing-check-0).
// 2. We do not have a good way to construct url::Origin from the string,
// *allow_origin_header, that may be broken. Unfortunately
// url::Origin::Create(GURL(*allow_origin_header)) accepts malformed
// URL and constructs a valid origin with unexpected fixes, which
// results in unexpected behavior.
// We run some more value checks below to provide better information to
// developers.
// Does not allow to have multiple origins in the allow origin header.
// See https://fetch.spec.whatwg.org/#http-access-control-allow-origin.
if (allow_origin_header->find_first_of(" ,") != std::string::npos) {
return CorsErrorStatus(mojom::CorsError::kMultipleAllowOriginValues,
*allow_origin_header);
}
// Check valid "null" first since GURL assumes it as invalid.
if (*allow_origin_header == "null") {
return CorsErrorStatus(mojom::CorsError::kAllowOriginMismatch,
*allow_origin_header);
}
// As commented above, this validation is not strict as an origin
// validation, but should be ok for providing error details to developers.
GURL header_origin(*allow_origin_header);
if (!header_origin.is_valid()) {
return CorsErrorStatus(mojom::CorsError::kInvalidAllowOriginValue,
*allow_origin_header);
}
return CorsErrorStatus(mojom::CorsError::kAllowOriginMismatch,
*allow_origin_header);
}
if (credentials_mode == mojom::CredentialsMode::kInclude) {
// https://fetch.spec.whatwg.org/#http-access-control-allow-credentials.
// This check should be case sensitive.
// See also https://fetch.spec.whatwg.org/#http-new-header-syntax.
if (allow_credentials_header != kLowerCaseTrue) {
return CorsErrorStatus(mojom::CorsError::kInvalidAllowCredentials,
allow_credentials_header.value_or(std::string()));
}
}
return base::nullopt;
}
// These values are used for logging to UMA. Entries should not be renumbered
// and numeric values should never be reused. Please keep in sync with
// "CorsAccessCheckResult" in src/tools/metrics/histograms/enums.xml.
enum class AccessCheckResult {
kPermitted = 0,
kNotPermitted = 1,
kPermittedInPreflight = 2,
kNotPermittedInPreflight = 3,
kMaxValue = kNotPermittedInPreflight,
};
void ReportAccessCheckResultMetric(AccessCheckResult result,
const url::Origin& requestor_origin) {
UMA_HISTOGRAM_ENUMERATION("Net.Cors.AccessCheckResult", result);
if (!IsOriginPotentiallyTrustworthy(requestor_origin)) {
UMA_HISTOGRAM_ENUMERATION("Net.Cors.AccessCheckResult.NotSecureRequestor",
result);
}
}
} // namespace
namespace cors {
namespace header_names {
const char kAccessControlAllowCredentials[] =
"Access-Control-Allow-Credentials";
const char kAccessControlAllowExternal[] = "Access-Control-Allow-External";
const char kAccessControlAllowHeaders[] = "Access-Control-Allow-Headers";
const char kAccessControlAllowMethods[] = "Access-Control-Allow-Methods";
const char kAccessControlAllowOrigin[] = "Access-Control-Allow-Origin";
const char kAccessControlMaxAge[] = "Access-Control-Max-Age";
const char kAccessControlRequestExternal[] = "Access-Control-Request-External";
const char kAccessControlRequestHeaders[] = "Access-Control-Request-Headers";
const char kAccessControlRequestMethod[] = "Access-Control-Request-Method";
} // namespace header_names
// See https://fetch.spec.whatwg.org/#cors-check.
base::Optional<CorsErrorStatus> CheckAccess(
const GURL& response_url,
const base::Optional<std::string>& allow_origin_header,
const base::Optional<std::string>& allow_credentials_header,
mojom::CredentialsMode credentials_mode,
const url::Origin& origin) {
const auto error_status =
CheckAccessInternal(response_url, allow_origin_header,
allow_credentials_header, credentials_mode, origin);
ReportAccessCheckResultMetric(error_status ? AccessCheckResult::kNotPermitted
: AccessCheckResult::kPermitted,
origin);
if (error_status) {
UMA_HISTOGRAM_ENUMERATION("Net.Cors.AccessCheckError",
error_status->cors_error);
}
return error_status;
}
bool ShouldCheckCors(const GURL& request_url,
const base::Optional<url::Origin>& request_initiator,
mojom::RequestMode request_mode) {
if (request_mode == network::mojom::RequestMode::kNavigate ||
request_mode == network::mojom::RequestMode::kNoCors) {
return false;
}
// CORS needs a proper origin (including a unique opaque origin). If the
// request doesn't have one, CORS should not work.
DCHECK(request_initiator);
// |request_url| should not contain the url::kDataScheme here, but have a
// DCHECK for a while, just in case.
DCHECK(!request_url.SchemeIs(url::kDataScheme));
if (request_initiator->IsSameOriginWith(url::Origin::Create(request_url)))
return false;
return true;
}
base::Optional<CorsErrorStatus> CheckPreflightAccess(
const GURL& response_url,
const int response_status_code,
const base::Optional<std::string>& allow_origin_header,
const base::Optional<std::string>& allow_credentials_header,
mojom::CredentialsMode actual_credentials_mode,
const url::Origin& origin) {
// Step 7 of https://fetch.spec.whatwg.org/#cors-preflight-fetch
auto error_status = CheckAccessInternal(response_url, allow_origin_header,
allow_credentials_header,
actual_credentials_mode, origin);
const bool has_ok_status = IsOkStatus(response_status_code);
ReportAccessCheckResultMetric(
(error_status || !has_ok_status)
? AccessCheckResult::kNotPermittedInPreflight
: AccessCheckResult::kPermittedInPreflight,
origin);
// Prefer using a preflight specific error code.
if (error_status) {
switch (error_status->cors_error) {
case mojom::CorsError::kWildcardOriginNotAllowed:
error_status->cors_error =
mojom::CorsError::kPreflightWildcardOriginNotAllowed;
break;
case mojom::CorsError::kMissingAllowOriginHeader:
error_status->cors_error =
mojom::CorsError::kPreflightMissingAllowOriginHeader;
break;
case mojom::CorsError::kMultipleAllowOriginValues:
error_status->cors_error =
mojom::CorsError::kPreflightMultipleAllowOriginValues;
break;
case mojom::CorsError::kInvalidAllowOriginValue:
error_status->cors_error =
mojom::CorsError::kPreflightInvalidAllowOriginValue;
break;
case mojom::CorsError::kAllowOriginMismatch:
error_status->cors_error =
mojom::CorsError::kPreflightAllowOriginMismatch;
break;
case mojom::CorsError::kInvalidAllowCredentials:
error_status->cors_error =
mojom::CorsError::kPreflightInvalidAllowCredentials;
break;
default:
NOTREACHED();
break;
}
} else if (!has_ok_status) {
error_status = base::make_optional<CorsErrorStatus>(
mojom::CorsError::kPreflightInvalidStatus);
} else {
return base::nullopt;
}
UMA_HISTOGRAM_ENUMERATION("Net.Cors.PreflightCheckError",
error_status->cors_error);
return error_status;
}
base::Optional<CorsErrorStatus> CheckRedirectLocation(
const GURL& url,
mojom::RequestMode request_mode,
const base::Optional<url::Origin>& origin,
bool cors_flag,
bool tainted) {
// If |actualResponse|’s location URL’s scheme is not an HTTP(S) scheme,
// then return a network error.
// This should be addressed in //net.
// Note: The redirect count check is done elsewhere.
const bool url_has_credentials = url.has_username() || url.has_password();
// If |request|’s mode is "cors", |actualResponse|’s location URL includes
// credentials, and either |request|’s tainted origin flag is set or
// |request|’s origin is not same origin with |actualResponse|’s location
// URL’s origin, then return a network error.
DCHECK(!IsCorsEnabledRequestMode(request_mode) || origin);
if (IsCorsEnabledRequestMode(request_mode) && url_has_credentials &&
(tainted || !origin->IsSameOriginWith(url::Origin::Create(url)))) {
return CorsErrorStatus(mojom::CorsError::kRedirectContainsCredentials);
}
// If CORS flag is set and |actualResponse|’s location URL includes
// credentials, then return a network error.
if (cors_flag && url_has_credentials)
return CorsErrorStatus(mojom::CorsError::kRedirectContainsCredentials);
return base::nullopt;
}
// https://wicg.github.io/cors-rfc1918/#http-headerdef-access-control-allow-external
base::Optional<CorsErrorStatus> CheckExternalPreflight(
const base::Optional<std::string>& allow_external) {
if (!allow_external)
return CorsErrorStatus(mojom::CorsError::kPreflightMissingAllowExternal);
if (*allow_external == kLowerCaseTrue)
return base::nullopt;
return CorsErrorStatus(mojom::CorsError::kPreflightInvalidAllowExternal,
*allow_external);
}
bool IsCorsEnabledRequestMode(mojom::RequestMode mode) {
return mode == mojom::RequestMode::kCors ||
mode == mojom::RequestMode::kCorsWithForcedPreflight;
}
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;
}
bool IsCorsSafelistedContentType(const std::string& media_type) {
return IsCorsSafelistedLowerCaseContentType(base::ToLowerASCII(media_type));
}
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",
};
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;
}
bool IsNoCorsSafelistedHeaderName(const std::string& name) {
return IsNoCorsSafelistedHeaderNameLowerCase(base::ToLowerASCII(name));
}
bool IsPrivilegedNoCorsHeaderName(const std::string& name) {
const std::string lower_name = base::ToLowerASCII(name);
const std::vector<std::string> privileged_no_cors_header_names =
PrivilegedNoCorsHeaderNames();
for (const auto& header : privileged_no_cors_header_names) {
if (lower_name == header)
return true;
}
return false;
}
bool IsNoCorsSafelistedHeader(const std::string& name,
const std::string& value) {
const std::string lower_name = base::ToLowerASCII(name);
if (!IsNoCorsSafelistedHeaderNameLowerCase(lower_name))
return false;
return IsCorsSafelistedHeader(lower_name, value);
}
std::vector<std::string> CorsUnsafeRequestHeaderNames(
const net::HttpRequestHeaders::HeaderVector& headers) {
std::vector<std::string> potentially_unsafe_names;
std::vector<std::string> header_names;
constexpr size_t kSafeListValueSizeMax = 1024;
size_t safe_list_value_size = 0;
for (const auto& header : headers) {
if (!IsCorsSafelistedHeader(header.key, header.value)) {
header_names.push_back(base::ToLowerASCII(header.key));
} else {
potentially_unsafe_names.push_back(base::ToLowerASCII(header.key));
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;
}
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;
}
std::vector<std::string> PrivilegedNoCorsHeaderNames() {
return {"range"};
}
bool IsForbiddenMethod(const std::string& method) {
const std::string upper_method = base::ToUpperASCII(method);
return upper_method == net::HttpRequestHeaders::kConnectMethod ||
upper_method == net::HttpRequestHeaders::kTraceMethod ||
upper_method == net::HttpRequestHeaders::kTrackMethod;
}
bool IsOkStatus(int status) {
return status >= 200 && status < 300;
}
bool IsCorsSameOriginResponseType(mojom::FetchResponseType type) {
switch (type) {
case mojom::FetchResponseType::kBasic:
case mojom::FetchResponseType::kCors:
case mojom::FetchResponseType::kDefault:
return true;
case mojom::FetchResponseType::kError:
case mojom::FetchResponseType::kOpaque:
case mojom::FetchResponseType::kOpaqueRedirect:
return false;
}
}
bool IsCorsCrossOriginResponseType(mojom::FetchResponseType type) {
switch (type) {
case mojom::FetchResponseType::kBasic:
case mojom::FetchResponseType::kCors:
case mojom::FetchResponseType::kDefault:
case mojom::FetchResponseType::kError:
return false;
case mojom::FetchResponseType::kOpaque:
case mojom::FetchResponseType::kOpaqueRedirect:
return true;
}
}
bool CalculateCredentialsFlag(mojom::CredentialsMode credentials_mode,
mojom::FetchResponseType response_tainting) {
// Let |credentials flag| be set if one of
// - |request|’s credentials mode is "include"
// - |request|’s credentials mode is "same-origin" and |request|’s
// response tainting is "basic"
// is true, and unset otherwise.
switch (credentials_mode) {
case network::mojom::CredentialsMode::kOmit:
case network::mojom::CredentialsMode::kOmitBug_775438_Workaround:
return false;
case network::mojom::CredentialsMode::kSameOrigin:
return response_tainting == network::mojom::FetchResponseType::kBasic;
case network::mojom::CredentialsMode::kInclude:
return true;
}
}
mojom::FetchResponseType CalculateResponseType(
mojom::RequestMode mode,
bool is_request_considered_same_origin) {
if (is_request_considered_same_origin ||
mode == network::mojom::RequestMode::kNavigate ||
mode == network::mojom::RequestMode::kSameOrigin) {
return network::mojom::FetchResponseType::kBasic;
} else if (mode == network::mojom::RequestMode::kNoCors) {
return network::mojom::FetchResponseType::kOpaque;
} else {
DCHECK(network::cors::IsCorsEnabledRequestMode(mode)) << mode;
return network::mojom::FetchResponseType::kCors;
}
}
} // namespace cors
} // namespace network