| // Copyright 2020 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "content/browser/webauth/webauth_request_security_checker.h" |
| |
| #include "base/feature_list.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "content/browser/bad_message.h" |
| #include "content/public/browser/authenticator_request_client_delegate.h" |
| #include "content/public/browser/content_browser_client.h" |
| #include "content/public/browser/render_frame_host.h" |
| #include "content/public/common/content_client.h" |
| #include "content/public/common/content_features.h" |
| #include "device/fido/features.h" |
| #include "device/fido/fido_transport_protocol.h" |
| #include "net/base/registry_controlled_domains/registry_controlled_domain.h" |
| #include "services/network/public/cpp/is_potentially_trustworthy.h" |
| #include "third_party/abseil-cpp/absl/types/optional.h" |
| #include "third_party/blink/public/mojom/permissions_policy/permissions_policy_feature.mojom.h" |
| #include "third_party/blink/public/mojom/webauthn/authenticator.mojom.h" |
| #include "url/gurl.h" |
| #include "url/origin.h" |
| #include "url/url_util.h" |
| |
| namespace content { |
| |
| namespace { |
| |
| constexpr char kCryptotokenOrigin[] = |
| "chrome-extension://kmendfapggjehodndflmmgagdbamhnfd"; |
| |
| // Returns AuthenticatorStatus::SUCCESS if the caller origin is in principle |
| // authorized to make WebAuthn requests, and an error if it fails one of the |
| // criteria below. |
| // |
| // Reference https://url.spec.whatwg.org/#valid-domain-string and |
| // https://html.spec.whatwg.org/multipage/origin.html#concept-origin-effective-domain. |
| blink::mojom::AuthenticatorStatus OriginAllowedToMakeWebAuthnRequests( |
| url::Origin caller_origin) { |
| // For calls originating in the CryptoToken U2F extension, allow CryptoToken |
| // to validate domain. |
| if (WebAuthRequestSecurityChecker::OriginIsCryptoTokenExtension( |
| caller_origin)) { |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| if (caller_origin.opaque()) { |
| return blink::mojom::AuthenticatorStatus::OPAQUE_DOMAIN; |
| } |
| |
| // The scheme is required to be HTTP(S). Given the |
| // |network::IsUrlPotentiallyTrustworthy| check below, HTTP is effectively |
| // restricted to just "localhost". |
| if (caller_origin.scheme() != url::kHttpScheme && |
| caller_origin.scheme() != url::kHttpsScheme) { |
| return blink::mojom::AuthenticatorStatus::INVALID_PROTOCOL; |
| } |
| |
| // TODO(https://crbug.com/1158302): Use IsOriginPotentiallyTrustworthy? |
| if (url::HostIsIPAddress(caller_origin.host()) || |
| !network::IsUrlPotentiallyTrustworthy(caller_origin.GetURL())) { |
| return blink::mojom::AuthenticatorStatus::INVALID_DOMAIN; |
| } |
| |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| // Returns whether a caller origin is allowed to claim a given Relying Party ID. |
| // It's valid for the requested RP ID to be a registrable domain suffix of, or |
| // be equal to, the origin's effective domain. Reference: |
| // https://html.spec.whatwg.org/multipage/origin.html#is-a-registrable-domain-suffix-of-or-is-equal-to. |
| bool OriginIsAllowedToClaimRelyingPartyId( |
| const std::string& claimed_relying_party_id, |
| const url::Origin& caller_origin) { |
| // `OriginAllowedToMakeWebAuthnRequests()` must have been called before. |
| DCHECK_EQ(OriginAllowedToMakeWebAuthnRequests(caller_origin), |
| blink::mojom::AuthenticatorStatus::SUCCESS); |
| |
| if (WebAuthRequestSecurityChecker::OriginIsCryptoTokenExtension( |
| caller_origin)) { |
| // This code trusts cryptotoken to handle the validation itself. |
| return true; |
| } |
| |
| if (claimed_relying_party_id.empty()) { |
| return false; |
| } |
| |
| if (caller_origin.host() == claimed_relying_party_id) { |
| return true; |
| } |
| |
| if (!caller_origin.DomainIs(claimed_relying_party_id)) { |
| return false; |
| } |
| |
| if (!net::registry_controlled_domains::HostHasRegistryControlledDomain( |
| caller_origin.host(), |
| net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES, |
| net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES) || |
| !net::registry_controlled_domains::HostHasRegistryControlledDomain( |
| claimed_relying_party_id, |
| net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES, |
| net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES)) { |
| // TODO(crbug.com/803414): Accept corner-case situations like the |
| // following origin: "https://login.awesomecompany", relying_party_id: |
| // "awesomecompany". |
| return false; |
| } |
| |
| return true; |
| } |
| |
| } // namespace |
| |
| WebAuthRequestSecurityChecker::WebAuthRequestSecurityChecker( |
| RenderFrameHost* host) |
| : render_frame_host_(host) {} |
| |
| WebAuthRequestSecurityChecker::~WebAuthRequestSecurityChecker() = default; |
| |
| bool WebAuthRequestSecurityChecker::OriginIsCryptoTokenExtension( |
| const url::Origin& origin) { |
| return origin == url::Origin::Create(GURL(kCryptotokenOrigin)); |
| } |
| |
| bool WebAuthRequestSecurityChecker::IsSameOriginWithAncestors( |
| const url::Origin& origin) { |
| RenderFrameHost* parent = render_frame_host_->GetParentOrOuterDocument(); |
| while (parent) { |
| if (!parent->GetLastCommittedOrigin().IsSameOriginWith(origin)) |
| return false; |
| parent = parent->GetParentOrOuterDocument(); |
| } |
| return true; |
| } |
| |
| blink::mojom::AuthenticatorStatus |
| WebAuthRequestSecurityChecker::ValidateAncestorOrigins( |
| const url::Origin& origin, |
| RequestType type, |
| bool* is_cross_origin) { |
| if (render_frame_host_->IsNestedWithinFencedFrame()) { |
| bad_message::ReceivedBadMessage( |
| render_frame_host_->GetProcess(), |
| bad_message::BadMessageReason::AUTH_INVALID_FENCED_FRAME); |
| return blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR; |
| } |
| |
| *is_cross_origin = !IsSameOriginWithAncestors(origin); |
| |
| // MakeCredential requests do not have an associated permissions policy, but |
| // are prohibited in cross-origin subframes. |
| if (!*is_cross_origin && type == RequestType::kMakeCredential) { |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| // Requests in cross-origin iframes are permitted if enabled via permissions |
| // policy and for SPC requests. |
| if (type == RequestType::kGetAssertion && |
| render_frame_host_->IsFeatureEnabled( |
| blink::mojom::PermissionsPolicyFeature::kPublicKeyCredentialsGet)) { |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| if ((type == RequestType::kMakePaymentCredential || |
| type == RequestType::kGetPaymentCredentialAssertion) && |
| render_frame_host_->IsFeatureEnabled( |
| blink::mojom::PermissionsPolicyFeature::kPayment)) { |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| return blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR; |
| } |
| |
| blink::mojom::AuthenticatorStatus |
| WebAuthRequestSecurityChecker::ValidateDomainAndRelyingPartyID( |
| const url::Origin& caller_origin, |
| const std::string& relying_party_id, |
| RequestType request_type, |
| const blink::mojom::RemoteDesktopClientOverridePtr& |
| remote_desktop_client_override) { |
| if (GetContentClient() |
| ->browser() |
| ->GetWebAuthenticationDelegate() |
| ->OverrideCallerOriginAndRelyingPartyIdValidation( |
| render_frame_host_->GetBrowserContext(), caller_origin, |
| relying_party_id)) { |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| blink::mojom::AuthenticatorStatus domain_validation = |
| OriginAllowedToMakeWebAuthnRequests(caller_origin); |
| if (domain_validation != blink::mojom::AuthenticatorStatus::SUCCESS) { |
| return domain_validation; |
| } |
| |
| // SecurePaymentConfirmation allows third party payment service provider to |
| // get assertions on behalf of the Relying Parties. Hence it is not required |
| // for the RP ID to be a registrable suffix of the caller origin, as it would |
| // be for WebAuthn requests. |
| if (request_type == RequestType::kGetPaymentCredentialAssertion) |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| |
| url::Origin relying_party_origin = caller_origin; |
| if (remote_desktop_client_override) { |
| if (!GetContentClient() |
| ->browser() |
| ->GetWebAuthenticationDelegate() |
| ->OriginMayUseRemoteDesktopClientOverride( |
| render_frame_host_->GetBrowserContext(), caller_origin)) { |
| return blink::mojom::AuthenticatorStatus:: |
| REMOTE_DESKTOP_CLIENT_OVERRIDE_NOT_AUTHORIZED; |
| } |
| relying_party_origin = remote_desktop_client_override->origin; |
| } |
| |
| if (!OriginIsAllowedToClaimRelyingPartyId(relying_party_id, |
| relying_party_origin)) { |
| return blink::mojom::AuthenticatorStatus::BAD_RELYING_PARTY_ID; |
| } |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| blink::mojom::AuthenticatorStatus |
| WebAuthRequestSecurityChecker::ValidateAppIdExtension( |
| std::string appid, |
| url::Origin caller_origin, |
| const blink::mojom::RemoteDesktopClientOverridePtr& |
| remote_desktop_client_override, |
| std::string* out_appid) { |
| // The CryptoToken U2F extension checks the appid before calling the WebAuthn |
| // API so there is no need to validate it here. |
| if (OriginIsCryptoTokenExtension(caller_origin)) { |
| DCHECK(!remote_desktop_client_override); |
| if (!GURL(appid).is_valid()) { |
| NOTREACHED() << "cryptotoken request did not set a valid App ID"; |
| return blink::mojom::AuthenticatorStatus::INVALID_DOMAIN; |
| } |
| *out_appid = appid; |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| if (remote_desktop_client_override) { |
| if (!GetContentClient() |
| ->browser() |
| ->GetWebAuthenticationDelegate() |
| ->OriginMayUseRemoteDesktopClientOverride( |
| render_frame_host_->GetBrowserContext(), caller_origin)) { |
| return blink::mojom::AuthenticatorStatus:: |
| REMOTE_DESKTOP_CLIENT_OVERRIDE_NOT_AUTHORIZED; |
| } |
| caller_origin = remote_desktop_client_override->origin; |
| } |
| |
| // Step 1: "If the AppID is not an HTTPS URL, and matches the FacetID of the |
| // caller, no additional processing is necessary and the operation may |
| // proceed." |
| |
| // Webauthn is only supported on secure origins and |
| // `ValidateDomainAndRelyingPartyID()` has already checked this property of |
| // `caller_origin` before this call. Thus this step is moot. |
| DCHECK(network::IsOriginPotentiallyTrustworthy(caller_origin)); |
| |
| // Step 2: "If the AppID is null or empty, the client must set the AppID to be |
| // the FacetID of the caller, and the operation may proceed without additional |
| // processing." |
| if (appid.empty()) { |
| // While the U2F spec says to default the App ID to the Facet ID, which is |
| // the origin plus a trailing forward slash [1], cryptotoken and Firefox |
| // just use the site's Origin without trailing slash. We follow their |
| // implementations rather than the spec. |
| // |
| // [1]https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-appid-and-facets-v2.0-id-20180227.html#determining-the-facetid-of-a-calling-application |
| appid = caller_origin.Serialize(); |
| } |
| |
| // Step 3: "If the caller's FacetID is an https:// Origin sharing the same |
| // host as the AppID, (e.g. if an application hosted at |
| // https://fido.example.com/myApp set an AppID of |
| // https://fido.example.com/myAppId), no additional processing is necessary |
| // and the operation may proceed." |
| GURL appid_url = GURL(appid); |
| if (!appid_url.is_valid() || appid_url.scheme() != url::kHttpsScheme || |
| appid_url.scheme_piece() != caller_origin.scheme()) { |
| return blink::mojom::AuthenticatorStatus::INVALID_DOMAIN; |
| } |
| |
| // This check is repeated inside |SameDomainOrHost|, just after this. However |
| // it's cheap and mirrors the structure of the spec. |
| if (appid_url.host_piece() == caller_origin.host()) { |
| *out_appid = appid; |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| // At this point we diverge from the specification in order to avoid the |
| // complexity of making a network request which isn't believed to be |
| // necessary in practice. See also |
| // https://bugzilla.mozilla.org/show_bug.cgi?id=1244959#c8 |
| if (net::registry_controlled_domains::SameDomainOrHost( |
| appid_url, caller_origin, |
| net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES)) { |
| *out_appid = appid; |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| // As a compatibility hack, sites within google.com are allowed to assert two |
| // special-case AppIDs. Firefox also does this: |
| // https://groups.google.com/forum/#!msg/mozilla.dev.platform/Uiu3fwnA2xw/201ynAiPAQAJ |
| const GURL gstatic_appid(kGstaticAppId); |
| const GURL gstatic_corp_appid(kGstaticCorpAppId); |
| DCHECK(gstatic_appid.is_valid() && gstatic_corp_appid.is_valid()); |
| if (caller_origin.DomainIs("google.com") && !appid_url.has_ref() && |
| (appid_url.EqualsIgnoringRef(gstatic_appid) || |
| appid_url.EqualsIgnoringRef(gstatic_corp_appid))) { |
| *out_appid = appid; |
| return blink::mojom::AuthenticatorStatus::SUCCESS; |
| } |
| |
| return blink::mojom::AuthenticatorStatus::INVALID_DOMAIN; |
| } |
| |
| bool WebAuthRequestSecurityChecker:: |
| DeduplicateCredentialDescriptorListAndValidateLength( |
| std::vector<device::PublicKeyCredentialDescriptor>* list) { |
| // Credential descriptor lists should not exceed 64 entries, which is enforced |
| // by renderer code. Any duplicate entries they contain should be ignored. |
| // This is to guard against sites trying to amplify small timing differences |
| // in the processing of different types of credentials when sending probing |
| // requests to physical security keys (https://crbug.com/1248862). |
| if (list->size() > blink::mojom::kPublicKeyCredentialDescriptorListMaxSize) { |
| return false; |
| } |
| auto credential_descriptor_compare_without_transport = |
| [](const device::PublicKeyCredentialDescriptor& a, |
| const device::PublicKeyCredentialDescriptor& b) { |
| return a.credential_type < b.credential_type || |
| (a.credential_type == b.credential_type && a.id < b.id); |
| }; |
| std::set<device::PublicKeyCredentialDescriptor, |
| decltype(credential_descriptor_compare_without_transport)> |
| unique_credential_descriptors( |
| credential_descriptor_compare_without_transport); |
| for (const auto& credential_descriptor : *list) { |
| auto it = unique_credential_descriptors.find(credential_descriptor); |
| if (it == unique_credential_descriptors.end()) { |
| unique_credential_descriptors.insert(credential_descriptor); |
| } else { |
| // Combine transport hints of descriptors with identical IDs. Empty |
| // transport list means _any_ transport, so the union should still be |
| // empty. |
| base::flat_set<device::FidoTransportProtocol> merged_transports; |
| if (!it->transports.empty() && |
| !credential_descriptor.transports.empty()) { |
| base::ranges::set_union( |
| it->transports, credential_descriptor.transports, |
| std::inserter(merged_transports, merged_transports.begin())); |
| } |
| unique_credential_descriptors.erase(it); |
| unique_credential_descriptors.insert( |
| {credential_descriptor.credential_type, credential_descriptor.id, |
| std::move(merged_transports)}); |
| } |
| } |
| *list = {unique_credential_descriptors.begin(), |
| unique_credential_descriptors.end()}; |
| return true; |
| } |
| |
| } // namespace content |