| // Copyright 2021 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "third_party/blink/renderer/core/speculation_rules/document_speculation_rules.h" |
| |
| #include "base/containers/contains.h" |
| #include "base/ranges/algorithm.h" |
| #include "third_party/blink/public/common/browser_interface_broker_proxy.h" |
| #include "third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom-shared.h" |
| #include "third_party/blink/renderer/core/dom/shadow_including_tree_order_traversal.h" |
| #include "third_party/blink/renderer/core/execution_context/agent.h" |
| #include "third_party/blink/renderer/core/execution_context/execution_context.h" |
| #include "third_party/blink/renderer/core/frame/local_frame.h" |
| #include "third_party/blink/renderer/core/html/html_anchor_element.h" |
| #include "third_party/blink/renderer/core/html/html_area_element.h" |
| #include "third_party/blink/renderer/core/inspector/console_message.h" |
| #include "third_party/blink/renderer/core/loader/speculation_rule_loader.h" |
| #include "third_party/blink/renderer/core/speculation_rules/document_rule_predicate.h" |
| #include "third_party/blink/renderer/core/speculation_rules/speculation_rules_metrics.h" |
| #include "third_party/blink/renderer/platform/scheduler/public/event_loop.h" |
| #include "third_party/blink/renderer/platform/weborigin/referrer.h" |
| #include "third_party/blink/renderer/platform/weborigin/security_origin.h" |
| #include "third_party/blink/renderer/platform/weborigin/security_policy.h" |
| #include "third_party/blink/renderer/platform/wtf/vector.h" |
| |
| namespace blink { |
| |
| namespace { |
| |
| // https://wicg.github.io/nav-speculation/prefetch.html#list-of-sufficiently-strict-speculative-navigation-referrer-policies |
| bool AcceptableReferrerPolicy(const Referrer& referrer, |
| bool is_initially_same_site) { |
| // Lax referrer policies are acceptable for same-site. The browser is |
| // responsible for aborting in the case of cross-site redirects with lax |
| // referrer policies. |
| if (is_initially_same_site) |
| return true; |
| |
| switch (referrer.referrer_policy) { |
| case network::mojom::ReferrerPolicy::kAlways: |
| case network::mojom::ReferrerPolicy::kNoReferrerWhenDowngrade: |
| case network::mojom::ReferrerPolicy::kOrigin: |
| case network::mojom::ReferrerPolicy::kOriginWhenCrossOrigin: |
| return false; |
| |
| case network::mojom::ReferrerPolicy::kNever: |
| case network::mojom::ReferrerPolicy::kSameOrigin: |
| case network::mojom::ReferrerPolicy::kStrictOrigin: |
| case network::mojom::ReferrerPolicy::kStrictOriginWhenCrossOrigin: |
| return true; |
| |
| case network::mojom::ReferrerPolicy::kDefault: |
| NOTREACHED(); |
| return false; |
| } |
| } |
| |
| String SpeculationActionAsString(mojom::blink::SpeculationAction action) { |
| switch (action) { |
| case mojom::blink::SpeculationAction::kPrefetch: |
| case mojom::blink::SpeculationAction::kPrefetchWithSubresources: |
| return "prefetch"; |
| case mojom::blink::SpeculationAction::kPrerender: |
| return "prerender"; |
| } |
| } |
| |
| String MakeReferrerWarning(mojom::blink::SpeculationAction action, |
| const KURL& url, |
| const Referrer& referrer) { |
| return "Ignored attempt to " + SpeculationActionAsString(action) + " " + |
| url.ElidedString() + " due to unacceptable referrer policy (" + |
| SecurityPolicy::ReferrerPolicyAsString(referrer.referrer_policy) + |
| ")."; |
| } |
| |
| // Computes a referrer based on a Speculation Rule, and its URL or the link it |
| // is matched against. Return absl::nullopt if the computed referrer policy is |
| // not acceptable (see AcceptableReferrerPolicy above). |
| absl::optional<Referrer> GetReferrer(SpeculationRule* rule, |
| ExecutionContext* execution_context, |
| mojom::blink::SpeculationAction action, |
| HTMLAnchorElement* link, |
| absl::optional<KURL> opt_url) { |
| DCHECK(link || opt_url); |
| bool using_link_referrer_policy = false; |
| network::mojom::ReferrerPolicy referrer_policy; |
| if (rule->referrer_policy()) { |
| referrer_policy = rule->referrer_policy().value(); |
| } else { |
| referrer_policy = execution_context->GetReferrerPolicy(); |
| if (link && link->HasRel(kRelationNoReferrer)) { |
| using_link_referrer_policy = true; |
| referrer_policy = network::mojom::ReferrerPolicy::kNever; |
| } else if (link && |
| link->FastHasAttribute(html_names::kReferrerpolicyAttr)) { |
| // Override |referrer_policy| with value derived from link's |
| // referrerpolicy attribute (if valid). |
| using_link_referrer_policy = SecurityPolicy::ReferrerPolicyFromString( |
| link->FastGetAttribute(html_names::kReferrerpolicyAttr), |
| kSupportReferrerPolicyLegacyKeywords, &referrer_policy); |
| } |
| } |
| |
| String outgoing_referrer = execution_context->OutgoingReferrer(); |
| KURL url = link ? link->HrefURL() : opt_url.value(); |
| scoped_refptr<const SecurityOrigin> url_origin = SecurityOrigin::Create(url); |
| const bool is_initially_same_site = |
| url_origin->IsSameSiteWith(execution_context->GetSecurityOrigin()); |
| Referrer referrer = |
| SecurityPolicy::GenerateReferrer(referrer_policy, url, outgoing_referrer); |
| |
| // TODO(mcnee): Speculation rules initially shipped with a bug where a policy |
| // of "no-referrer" would be assumed and the referrer policy restriction was |
| // not enforced. We emulate that behaviour here as sites did not have a means |
| // of specifying a suitable policy. SpeculationRulesReferrerPolicyKey shipped |
| // in M111. This workaround should be removed when the flag is removed. |
| // See https://crbug.com/1398772. |
| if (!RuntimeEnabledFeatures::SpeculationRulesReferrerPolicyKeyEnabled( |
| execution_context) && |
| !AcceptableReferrerPolicy(referrer, is_initially_same_site)) { |
| referrer = SecurityPolicy::GenerateReferrer( |
| network::mojom::ReferrerPolicy::kNever, url, outgoing_referrer); |
| DCHECK(AcceptableReferrerPolicy(referrer, is_initially_same_site)); |
| } |
| |
| if (!AcceptableReferrerPolicy(referrer, is_initially_same_site)) { |
| auto* console_message = MakeGarbageCollected<ConsoleMessage>( |
| mojom::blink::ConsoleMessageSource::kOther, |
| mojom::blink::ConsoleMessageLevel::kWarning, |
| MakeReferrerWarning(action, url, referrer)); |
| if (using_link_referrer_policy) { |
| console_message->SetNodes(link->GetDocument().GetFrame(), |
| {DOMNodeIds::IdForNode(link)}); |
| } |
| execution_context->AddConsoleMessage(console_message); |
| return absl::nullopt; |
| } |
| |
| return referrer; |
| } |
| |
| } // namespace |
| |
| // static |
| const char DocumentSpeculationRules::kSupplementName[] = |
| "DocumentSpeculationRules"; |
| |
| // static |
| DocumentSpeculationRules& DocumentSpeculationRules::From(Document& document) { |
| if (DocumentSpeculationRules* self = FromIfExists(document)) |
| return *self; |
| |
| auto* self = MakeGarbageCollected<DocumentSpeculationRules>(document); |
| ProvideTo(document, self); |
| return *self; |
| } |
| |
| // static |
| DocumentSpeculationRules* DocumentSpeculationRules::FromIfExists( |
| Document& document) { |
| return Supplement::From<DocumentSpeculationRules>(document); |
| } |
| |
| DocumentSpeculationRules::DocumentSpeculationRules(Document& document) |
| : Supplement(document), host_(document.GetExecutionContext()) {} |
| |
| void DocumentSpeculationRules::AddRuleSet(SpeculationRuleSet* rule_set) { |
| CountSpeculationRulesLoadOutcome(SpeculationRulesLoadOutcome::kSuccess); |
| DCHECK(!base::Contains(rule_sets_, rule_set)); |
| rule_sets_.push_back(rule_set); |
| if (rule_set->has_document_rule()) { |
| UseCounter::Count(GetSupplementable(), |
| WebFeature::kSpeculationRulesDocumentRules); |
| InitializeIfNecessary(); |
| InvalidateAllLinks(); |
| } |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::RemoveRuleSet(SpeculationRuleSet* rule_set) { |
| auto* it = base::ranges::remove(rule_sets_, rule_set); |
| DCHECK(it != rule_sets_.end()) << "rule set was removed without existing"; |
| rule_sets_.erase(it, rule_sets_.end()); |
| if (rule_set->has_document_rule()) |
| InvalidateAllLinks(); |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::AddSpeculationRuleLoader( |
| SpeculationRuleLoader* speculation_rule_loader) { |
| speculation_rule_loaders_.insert(speculation_rule_loader); |
| } |
| |
| void DocumentSpeculationRules::RemoveSpeculationRuleLoader( |
| SpeculationRuleLoader* speculation_rule_loader) { |
| speculation_rule_loaders_.erase(speculation_rule_loader); |
| } |
| |
| void DocumentSpeculationRules::LinkInserted(HTMLAnchorElement* link) { |
| if (!initialized_) |
| return; |
| |
| DCHECK(link->IsLink()); |
| DCHECK(link->isConnected()); |
| AddLink(link); |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::LinkRemoved(HTMLAnchorElement* link) { |
| if (!initialized_) |
| return; |
| |
| DCHECK(link->IsLink()); |
| RemoveLink(link); |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::HrefAttributeChanged( |
| HTMLAnchorElement* link, |
| const AtomicString& old_value, |
| const AtomicString& new_value) { |
| if (!initialized_) |
| return; |
| |
| DCHECK_NE(old_value, new_value); |
| DCHECK(link->isConnected()); |
| |
| if (old_value.IsNull()) |
| AddLink(link); |
| else if (new_value.IsNull()) |
| RemoveLink(link); |
| else |
| InvalidateLink(link); |
| |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::ReferrerPolicyAttributeChanged( |
| HTMLAnchorElement* link) { |
| if (!initialized_) |
| return; |
| |
| DCHECK(link->isConnected()); |
| InvalidateLink(link); |
| |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::RelAttributeChanged(HTMLAnchorElement* link) { |
| if (!initialized_) |
| return; |
| |
| DCHECK(link->isConnected()); |
| InvalidateLink(link); |
| |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::DocumentReferrerPolicyChanged() { |
| if (!initialized_) |
| return; |
| |
| InvalidateAllLinks(); |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::DocumentBaseURLChanged() { |
| // Replace every existing rule set with a new copy that is parsed using the |
| // updated document base URL. |
| for (Member<SpeculationRuleSet>& rule_set : rule_sets_) { |
| SpeculationRuleSet::Source* source = rule_set->source(); |
| String parse_error; |
| rule_set = SpeculationRuleSet::Parse( |
| source, GetSupplementable()->GetExecutionContext(), &parse_error); |
| // There should not be any parsing errors as these rule sets have already |
| // been parsed once without errors, and an updated base URL should not cause |
| // new errors. |
| DCHECK(parse_error.empty()); |
| } |
| if (initialized_) |
| InvalidateAllLinks(); |
| QueueUpdateSpeculationCandidates(); |
| } |
| |
| void DocumentSpeculationRules::Trace(Visitor* visitor) const { |
| Supplement::Trace(visitor); |
| visitor->Trace(rule_sets_); |
| visitor->Trace(host_); |
| visitor->Trace(speculation_rule_loaders_); |
| visitor->Trace(matched_links_); |
| visitor->Trace(unmatched_links_); |
| visitor->Trace(pending_links_); |
| } |
| |
| mojom::blink::SpeculationHost* DocumentSpeculationRules::GetHost() { |
| if (!host_.is_bound()) { |
| auto* execution_context = GetSupplementable()->GetExecutionContext(); |
| if (!execution_context) |
| return nullptr; |
| execution_context->GetBrowserInterfaceBroker().GetInterface( |
| host_.BindNewPipeAndPassReceiver( |
| execution_context->GetTaskRunner(TaskType::kInternalDefault))); |
| } |
| return host_.get(); |
| } |
| |
| void DocumentSpeculationRules::QueueUpdateSpeculationCandidates() { |
| if (has_pending_update_) |
| return; |
| |
| auto* execution_context = GetSupplementable()->GetExecutionContext(); |
| if (!execution_context) |
| return; |
| |
| has_pending_update_ = true; |
| execution_context->GetAgent()->event_loop()->EnqueueMicrotask( |
| WTF::BindOnce(&DocumentSpeculationRules::UpdateSpeculationCandidates, |
| WrapWeakPersistent(this))); |
| } |
| |
| void DocumentSpeculationRules::UpdateSpeculationCandidates() { |
| has_pending_update_ = false; |
| |
| mojom::blink::SpeculationHost* host = GetHost(); |
| auto* execution_context = GetSupplementable()->GetExecutionContext(); |
| if (!host || !execution_context) |
| return; |
| |
| Vector<mojom::blink::SpeculationCandidatePtr> candidates; |
| auto push_candidates = [&candidates, &execution_context]( |
| mojom::blink::SpeculationAction action, |
| const HeapVector<Member<SpeculationRule>>& rules) { |
| for (SpeculationRule* rule : rules) { |
| for (const KURL& url : rule->urls()) { |
| absl::optional<Referrer> referrer = |
| GetReferrer(rule, execution_context, action, /*link=*/nullptr, url); |
| if (!referrer) |
| continue; |
| |
| auto referrer_ptr = mojom::blink::Referrer::New( |
| KURL(referrer->referrer), referrer->referrer_policy); |
| candidates.push_back(mojom::blink::SpeculationCandidate::New( |
| url, action, std::move(referrer_ptr), |
| rule->requires_anonymous_client_ip_when_cross_origin(), |
| rule->target_browsing_context_name_hint().value_or( |
| mojom::blink::SpeculationTargetHint::kNoHint), |
| // The default Eagerness value for |"source": "list"| rules is |
| // |kEager|. More info can be found here: |
| // https://github.com/WICG/nav-speculation/blob/main/triggers.md#eagerness |
| rule->eagerness().value_or( |
| mojom::blink::SpeculationEagerness::kEager))); |
| } |
| } |
| }; |
| |
| for (SpeculationRuleSet* rule_set : rule_sets_) { |
| // If kSpeculationRulesPrefetchProxy is enabled, collect all prefetch |
| // speculation rules. |
| if (RuntimeEnabledFeatures::SpeculationRulesPrefetchProxyEnabled( |
| execution_context)) { |
| push_candidates(mojom::blink::SpeculationAction::kPrefetch, |
| rule_set->prefetch_rules()); |
| } |
| |
| // Ditto for SpeculationRulesPrefetchWithSubresources. |
| if (RuntimeEnabledFeatures::SpeculationRulesPrefetchWithSubresourcesEnabled( |
| execution_context)) { |
| push_candidates( |
| mojom::blink::SpeculationAction::kPrefetchWithSubresources, |
| rule_set->prefetch_with_subresources_rules()); |
| } |
| |
| // If kPrerender2 is enabled, collect all prerender speculation rules. |
| if (RuntimeEnabledFeatures::Prerender2Enabled(execution_context)) { |
| push_candidates(mojom::blink::SpeculationAction::kPrerender, |
| rule_set->prerender_rules()); |
| |
| // Set the flag to evict the cached data of Session Storage when the |
| // document is frozen or unload to avoid reusing old data in the cache |
| // after the session storage has been modified by another renderer |
| // process. See crbug.com/1215680 for more details. |
| LocalFrame* frame = GetSupplementable()->GetFrame(); |
| if (frame && frame->IsMainFrame()) { |
| frame->SetEvictCachedSessionStorageOnFreezeOrUnload(); |
| } |
| } |
| } |
| |
| // Add candidates derived from document rule predicates. |
| AddLinkBasedSpeculationCandidates(candidates); |
| |
| if (!sent_is_part_of_no_vary_search_trial_ && |
| RuntimeEnabledFeatures::NoVarySearchPrefetchEnabled(execution_context)) { |
| sent_is_part_of_no_vary_search_trial_ = true; |
| host->EnableNoVarySearchSupport(); |
| } |
| host->UpdateSpeculationCandidates(std::move(candidates)); |
| } |
| |
| void DocumentSpeculationRules::AddLinkBasedSpeculationCandidates( |
| Vector<mojom::blink::SpeculationCandidatePtr>& candidates) { |
| // Match all the unmatched |
| while (!pending_links_.empty()) { |
| auto it = pending_links_.begin(); |
| HTMLAnchorElement* link = *it; |
| Vector<mojom::blink::SpeculationCandidatePtr> link_candidates; |
| ExecutionContext* execution_context = |
| GetSupplementable()->GetExecutionContext(); |
| DCHECK(execution_context); |
| |
| const auto push_link_candidates = |
| [&link, &link_candidates, &execution_context]( |
| mojom::blink::SpeculationAction action, |
| const HeapVector<Member<SpeculationRule>>& speculation_rules) { |
| for (SpeculationRule* rule : speculation_rules) { |
| if (!rule->predicate()) |
| continue; |
| if (!rule->predicate()->Matches(*link)) |
| continue; |
| |
| absl::optional<Referrer> referrer = |
| GetReferrer(rule, execution_context, action, link, |
| /*opt_url=*/absl::nullopt); |
| if (!referrer) |
| continue; |
| mojom::blink::ReferrerPtr referrer_ptr = |
| mojom::blink::Referrer::New(KURL(referrer->referrer), |
| referrer->referrer_policy); |
| |
| // TODO(crbug.com/1371522): We should be generating a target hint |
| // based on the link's target. |
| mojom::blink::SpeculationCandidatePtr candidate = |
| mojom::blink::SpeculationCandidate::New( |
| link->HrefURL(), action, std::move(referrer_ptr), |
| rule->requires_anonymous_client_ip_when_cross_origin(), |
| rule->target_browsing_context_name_hint().value_or( |
| mojom::blink::SpeculationTargetHint::kNoHint), |
| // The default Eagerness value for |"source": "document"| |
| // rules is |kConservative|. More info can be found here: |
| // https://github.com/WICG/nav-speculation/blob/main/triggers.md#eagerness |
| rule->eagerness().value_or( |
| mojom::blink::SpeculationEagerness::kConservative)); |
| link_candidates.push_back(std::move(candidate)); |
| } |
| }; |
| |
| for (SpeculationRuleSet* rule_set : rule_sets_) { |
| if (RuntimeEnabledFeatures::SpeculationRulesPrefetchProxyEnabled( |
| execution_context)) { |
| push_link_candidates(mojom::blink::SpeculationAction::kPrefetch, |
| rule_set->prefetch_rules()); |
| } |
| |
| if (RuntimeEnabledFeatures:: |
| SpeculationRulesPrefetchWithSubresourcesEnabled( |
| execution_context)) { |
| push_link_candidates( |
| mojom::blink::SpeculationAction::kPrefetchWithSubresources, |
| rule_set->prefetch_with_subresources_rules()); |
| } |
| |
| if (RuntimeEnabledFeatures::Prerender2Enabled(execution_context)) { |
| push_link_candidates(mojom::blink::SpeculationAction::kPrerender, |
| rule_set->prerender_rules()); |
| } |
| } |
| |
| if (!link_candidates.empty()) |
| matched_links_.Set(link, std::move(link_candidates)); |
| else |
| unmatched_links_.insert(link); |
| |
| pending_links_.erase(it); |
| } |
| |
| for (auto& it : matched_links_) { |
| for (const auto& candidate : it.value) { |
| candidates.push_back(candidate.Clone()); |
| } |
| } |
| } |
| |
| void DocumentSpeculationRules::InitializeIfNecessary() { |
| if (initialized_) |
| return; |
| initialized_ = true; |
| for (Node& node : |
| ShadowIncludingTreeOrderTraversal::DescendantsOf(*GetSupplementable())) { |
| if (!node.IsLink()) |
| continue; |
| if (auto* anchor = DynamicTo<HTMLAnchorElement>(node)) |
| pending_links_.insert(anchor); |
| else if (auto* area = DynamicTo<HTMLAreaElement>(node)) |
| pending_links_.insert(area); |
| } |
| } |
| |
| void DocumentSpeculationRules::AddLink(HTMLAnchorElement* link) { |
| DCHECK(initialized_); |
| DCHECK(link->IsLink()); |
| DCHECK(!base::Contains(unmatched_links_, link)); |
| DCHECK(!base::Contains(matched_links_, link)); |
| DCHECK(!base::Contains(pending_links_, link)); |
| |
| pending_links_.insert(link); |
| } |
| |
| void DocumentSpeculationRules::RemoveLink(HTMLAnchorElement* link) { |
| DCHECK(initialized_); |
| |
| if (auto it = matched_links_.find(link); it != matched_links_.end()) { |
| matched_links_.erase(it); |
| DCHECK(!base::Contains(unmatched_links_, link)); |
| DCHECK(!base::Contains(pending_links_, link)); |
| return; |
| } |
| // TODO(crbug.com/1371522): Removing a link that doesn't match anything isn't |
| // going to change the candidate list, we could skip calling |
| // QueueUpdateSpeculationCandidates in this scenario. |
| if (auto it = unmatched_links_.find(link); it != unmatched_links_.end()) { |
| unmatched_links_.erase(it); |
| DCHECK(!base::Contains(pending_links_, link)); |
| return; |
| } |
| auto it = pending_links_.find(link); |
| DCHECK(it != pending_links_.end()); |
| pending_links_.erase(it); |
| } |
| |
| void DocumentSpeculationRules::InvalidateLink(HTMLAnchorElement* link) { |
| DCHECK(initialized_); |
| |
| pending_links_.insert(link); |
| if (auto it = matched_links_.find(link); it != matched_links_.end()) { |
| matched_links_.erase(it); |
| DCHECK(!base::Contains(unmatched_links_, link)); |
| return; |
| } |
| if (auto it = unmatched_links_.find(link); it != unmatched_links_.end()) |
| unmatched_links_.erase(it); |
| } |
| |
| void DocumentSpeculationRules::InvalidateAllLinks() { |
| DCHECK(initialized_); |
| |
| for (const auto& it : matched_links_) |
| pending_links_.insert(it.key); |
| matched_links_.clear(); |
| |
| for (HTMLAnchorElement* link : unmatched_links_) |
| pending_links_.insert(link); |
| unmatched_links_.clear(); |
| } |
| |
| } // namespace blink |