/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim:set ts=2 sw=2 sts=2 et cindent: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #include "FragmentDirective.h" #include #include "RangeBoundary.h" #include "mozilla/Assertions.h" #include "BasePrincipal.h" #include "Document.h" #include "TextDirectiveCreator.h" #include "TextDirectiveFinder.h" #include "TextDirectiveUtil.h" #include "mozilla/ResultVariant.h" #include "mozilla/dom/BrowsingContext.h" #include "mozilla/dom/BrowsingContextGroup.h" #include "mozilla/dom/FragmentDirectiveBinding.h" #include "mozilla/dom/FragmentOrElement.h" #include "mozilla/dom/Promise.h" #include "mozilla/dom/Selection.h" #include "mozilla/glean/DomMetrics.h" #include "mozilla/PresShell.h" #include "nsContentUtils.h" #include "nsDocShell.h" #include "nsICSSDeclaration.h" #include "nsIFrame.h" #include "nsINode.h" #include "nsIURIMutator.h" #include "nsRange.h" #include "nsString.h" namespace mozilla::dom { NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(FragmentDirective, mDocument) NS_IMPL_CYCLE_COLLECTING_ADDREF(FragmentDirective) NS_IMPL_CYCLE_COLLECTING_RELEASE(FragmentDirective) NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(FragmentDirective) NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY NS_INTERFACE_MAP_ENTRY(nsISupports) NS_INTERFACE_MAP_END FragmentDirective::FragmentDirective(Document* aDocument) : mDocument(aDocument) {} FragmentDirective::~FragmentDirective() = default; JSObject* FragmentDirective::WrapObject(JSContext* aCx, JS::Handle aGivenProto) { return FragmentDirective_Binding::Wrap(aCx, this, aGivenProto); } void FragmentDirective::SetTextDirectives( nsTArray&& aTextDirectives) { MOZ_ASSERT(mDocument); if (!aTextDirectives.IsEmpty()) { mFinder = MakeUnique(*mDocument, std::move(aTextDirectives)); } else { mFinder = nullptr; } } void FragmentDirective::ClearUninvokedDirectives() { mFinder = nullptr; } bool FragmentDirective::HasUninvokedDirectives() const { return !!mFinder; }; bool FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragmentString( nsCString& aFragment, nsTArray* aTextDirectives, nsIURI* aURI) { auto uri = TextDirectiveUtil::ShouldLog() && aURI ? aURI->GetSpecOrDefault() : nsCString(); if (aFragment.IsEmpty()) { TEXT_FRAGMENT_LOG("URL '{}' has no fragment.", uri); return false; } TEXT_FRAGMENT_LOG( "Trying to extract a fragment directive from fragment '{}' of URL '{}'.", aFragment, uri); ParsedFragmentDirectiveResult fragmentDirective; const bool hasRemovedFragmentDirective = StaticPrefs::dom_text_fragments_enabled() && parse_fragment_directive(&aFragment, &fragmentDirective); if (hasRemovedFragmentDirective) { TEXT_FRAGMENT_LOG( "Found a fragment directive '{}', which was removed from the fragment. " "New fragment is '{}'.", fragmentDirective.fragment_directive, fragmentDirective.hash_without_fragment_directive); if (TextDirectiveUtil::ShouldLog()) { if (fragmentDirective.text_directives.IsEmpty()) { TEXT_FRAGMENT_LOG( "Found no valid text directives in fragment directive '{}'.", fragmentDirective.fragment_directive); } else { TEXT_FRAGMENT_LOG( "Found {} valid text directives in fragment directive '{}':", fragmentDirective.text_directives.Length(), fragmentDirective.fragment_directive); for (size_t index = 0; index < fragmentDirective.text_directives.Length(); ++index) { const auto& textDirective = fragmentDirective.text_directives[index]; TEXT_FRAGMENT_LOG(" [{}]: {}", index, ToString(textDirective)); } } } aFragment = fragmentDirective.hash_without_fragment_directive; if (aTextDirectives) { aTextDirectives->SwapElements(fragmentDirective.text_directives); } } else { TEXT_FRAGMENT_LOG( "Fragment '{}' of URL '{}' did not contain a fragment directive.", aFragment, uri); } return hasRemovedFragmentDirective; } void FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragment( nsCOMPtr& aURI, nsTArray* aTextDirectives) { if (!aURI || !StaticPrefs::dom_text_fragments_enabled()) { return; } bool hasRef = false; aURI->GetHasRef(&hasRef); nsAutoCString hash; aURI->GetRef(hash); if (!hasRef || hash.IsEmpty()) { TEXT_FRAGMENT_LOG("URL '{}' has no fragment. Exiting.", aURI->GetSpecOrDefault()); } const bool hasRemovedFragmentDirective = ParseAndRemoveFragmentDirectiveFromFragmentString(hash, aTextDirectives, aURI); if (!hasRemovedFragmentDirective) { return; } Unused << NS_MutateURI(aURI).SetRef(hash).Finalize(aURI); TEXT_FRAGMENT_LOG("Updated hash of the URL. New URL: {}", aURI->GetSpecOrDefault()); } nsTArray> FragmentDirective::FindTextFragmentsInDocument() { MOZ_ASSERT(mDocument); if (!mFinder) { auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI() ? mDocument->GetDocumentURI()->GetSpecOrDefault() : nsCString(); TEXT_FRAGMENT_LOG("No uninvoked text directives in document '{}'. Exiting.", uri); return {}; } auto textDirectives = mFinder->FindTextDirectivesInDocument(); if (!mFinder->HasUninvokedDirectives()) { mFinder = nullptr; } return textDirectives; } /* static */ nsresult FragmentDirective::GetSpecIgnoringFragmentDirective( nsCOMPtr& aURI, nsACString& aSpecIgnoringFragmentDirective) { bool hasRef = false; if (aURI->GetHasRef(&hasRef); !hasRef) { return aURI->GetSpec(aSpecIgnoringFragmentDirective); } nsAutoCString ref; nsresult rv = aURI->GetRef(ref); if (NS_FAILED(rv)) { return rv; } rv = aURI->GetSpecIgnoringRef(aSpecIgnoringFragmentDirective); if (NS_FAILED(rv)) { return rv; } ParseAndRemoveFragmentDirectiveFromFragmentString(ref); if (!ref.IsEmpty()) { aSpecIgnoringFragmentDirective.Append('#'); aSpecIgnoringFragmentDirective.Append(ref); } return NS_OK; } bool FragmentDirective::IsTextDirectiveAllowedToBeScrolledTo() { // This method follows // https://wicg.github.io/scroll-to-text-fragment/#check-if-a-text-directive-can-be-scrolled // However, there are some spec issues // (https://github.com/WICG/scroll-to-text-fragment/issues/240). // The web-platform tests currently seem more up-to-date. Therefore, // this method is adapted slightly to make sure all tests pass. // Comments are added to explain changes. MOZ_ASSERT(mDocument); auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI() ? mDocument->GetDocumentURI()->GetSpecOrDefault() : nsCString(); TEXT_FRAGMENT_LOG( "Trying to find out if the load of URL '{}' is allowed to scroll to the " "text fragment", uri); // It seems the spec does not cover same-document navigation in particular, // or Gecko needs to deal with this in a different way due to the // implementation not following the spec step-by-step. // Therefore, the following algorithm needs some adaptions to deal with // same-document navigations correctly. nsCOMPtr loadInfo = mDocument->GetChannel() ? mDocument->GetChannel()->LoadInfo() : nullptr; const bool isSameDocumentNavigation = loadInfo && loadInfo->GetIsSameDocumentNavigation(); TEXT_FRAGMENT_LOG("Current load is{} a same-document navigation.", isSameDocumentNavigation ? "" : " not"); // 1. If document's pending text directives field is null or empty, return // false. // --- // we don't store the *pending* text directives in this class, only the // *uninvoked* text directives (uninvoked = `TextDirective`, pending = // `nsRange`). // Uninvoked text directives are typically already processed into pending text // directives when this code is called. Pending text directives are handled by // the caller when this code runs; therefore, the caller should decide if this // method should be called or not. // 2. Let is user involved be true if: document's text directive user // activation is true, or user involvement is one of "activation" or "browser // UI"; false otherwise. // 3. Set document's text directive user activation to false. const bool textDirectiveUserActivation = mDocument->ConsumeTextDirectiveUserActivation(); TEXT_FRAGMENT_LOG( "Consumed Document's TextDirectiveUserActivation flag (value={})", textDirectiveUserActivation ? "true" : "false"); // 4. If document's content type is not a text directive allowing MIME type, // return false. const bool isAllowedMIMEType = [doc = this->mDocument, func = __FUNCTION__] { nsAutoString contentType; doc->GetContentType(contentType); TEXT_FRAGMENT_LOG_FN("Got document MIME type: {}", func, NS_ConvertUTF16toUTF8(contentType)); return contentType == u"text/html" || contentType == u"text/plain"; }(); if (!isAllowedMIMEType) { TEXT_FRAGMENT_LOG("Invalid document MIME type. Scrolling not allowed."); return false; } // 5. If user involvement is "browser UI", return true. // // If a navigation originates from browser UI, it's always ok to allow it // since it'll be user triggered and the page/script isn't providing the text // snippet. // // Note: The intent in this item is to distinguish cases where the app/page is // able to control the URL from those that are fully under the user's // control. In the former we want to prevent scrolling of the text fragment // unless the destination is loaded in a separate browsing context group (so // that the source cannot both control the text snippet and observe // side-effects in the navigation). There are some cases where "browser UI" // may be a grey area in this regard. E.g. an "open in new window" context // menu item when right clicking on a link. // // See sec-fetch-site [0] for a related discussion on how this applies. // [0] https://w3c.github.io/webappsec-fetch-metadata/#directly-user-initiated // --- // Gecko does not implement user involvement as defined in the spec. // However, if the triggering principal is the system principal, the load // has been triggered from browser chrome. This should be good enough for now. auto* triggeringPrincipal = loadInfo ? loadInfo->TriggeringPrincipal() : nullptr; const bool isTriggeredFromBrowserUI = triggeringPrincipal && triggeringPrincipal->IsSystemPrincipal(); if (isTriggeredFromBrowserUI) { TEXT_FRAGMENT_LOG( "The load is triggered from browser UI. Scrolling allowed."); return true; } TEXT_FRAGMENT_LOG("The load is not triggered from browser UI."); // 6. If is user involved is false, return false. // --- // same-document navigation is not mentioned in the spec. However, we run this // code also in same-document navigation cases. // Same-document navigation is allowed even without any user interaction. if (!textDirectiveUserActivation && !isSameDocumentNavigation) { TEXT_FRAGMENT_LOG( "User involvement is false and not same-document navigation. Scrolling " "not allowed."); return false; } // 7. If document's node navigable has a parent, return false. // --- // this is extended to ignore this rule if this is a same-document navigation // in an iframe, which is allowed when the document's origin matches the // initiator's origin (which is checked in step 8). nsDocShell* docShell = nsDocShell::Cast(mDocument->GetDocShell()); if (!isSameDocumentNavigation && (!docShell || !docShell->GetIsTopLevelContentDocShell())) { TEXT_FRAGMENT_LOG( "Document's node navigable has a parent and this is not a " "same-document navigation. Scrolling not allowed."); return false; } // 8. If initiator origin is non-null and document's origin is same origin // with initiator origin, return true. const bool isSameOrigin = [doc = this->mDocument, triggeringPrincipal] { auto* docPrincipal = doc->GetPrincipal(); return triggeringPrincipal && docPrincipal && docPrincipal->Equals(triggeringPrincipal); }(); if (isSameOrigin) { TEXT_FRAGMENT_LOG("Same origin. Scrolling allowed."); return true; } TEXT_FRAGMENT_LOG("Not same origin."); // 9. If document's browsing context's group's browsing context set has length // 1, return true. // // i.e. Only allow navigation from a cross-origin element/script if the // document is loaded in a noopener context. That is, a new top level browsing // context group to which the navigator does not have script access and which // can be placed into a separate process. if (BrowsingContextGroup* group = mDocument->GetBrowsingContext() ? mDocument->GetBrowsingContext()->Group() : nullptr) { const bool isNoOpenerContext = group->Toplevels().Length() == 1; if (!isNoOpenerContext) { TEXT_FRAGMENT_LOG( "Cross-origin + noopener=false. Scrolling not allowed."); } return isNoOpenerContext; } // 10.Otherwise, return false. TEXT_FRAGMENT_LOG("Scrolling not allowed."); return false; } void FragmentDirective::HighlightTextDirectives( const nsTArray>& aTextDirectiveRanges) { MOZ_ASSERT(mDocument); if (!StaticPrefs::dom_text_fragments_enabled()) { return; } auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI() ? mDocument->GetDocumentURI()->GetSpecOrDefault() : nsCString(); if (aTextDirectiveRanges.IsEmpty()) { TEXT_FRAGMENT_LOG( "No text directive ranges to highlight for document '{}'. Exiting.", uri); return; } TEXT_FRAGMENT_LOG( "Highlighting text directives for document '{}' ({} ranges).", uri, aTextDirectiveRanges.Length()); const RefPtr targetTextSelection = [doc = this->mDocument]() -> Selection* { if (auto* presShell = doc->GetPresShell()) { return presShell->GetCurrentSelection(SelectionType::eTargetText); } return nullptr; }(); if (!targetTextSelection) { return; } for (const RefPtr& range : aTextDirectiveRanges) { // Script won't be able to manipulate `aTextDirectiveRanges`, // therefore we can mark `range` as known live. targetTextSelection->AddRangeAndSelectFramesAndNotifyListeners( MOZ_KnownLive(*range), IgnoreErrors()); } } void FragmentDirective::GetTextDirectiveRanges( nsTArray>& aRanges) const { if (!StaticPrefs::dom_text_fragments_enabled()) { return; } auto* presShell = mDocument ? mDocument->GetPresShell() : nullptr; if (!presShell) { return; } RefPtr targetTextSelection = presShell->GetCurrentSelection(SelectionType::eTargetText); if (!targetTextSelection) { return; } aRanges.Clear(); for (uint32_t rangeIndex = 0; rangeIndex < targetTextSelection->RangeCount(); ++rangeIndex) { nsRange* range = targetTextSelection->GetRangeAt(rangeIndex); MOZ_ASSERT(range); aRanges.AppendElement(range); } } void FragmentDirective::RemoveAllTextDirectives(ErrorResult& aRv) { if (!StaticPrefs::dom_text_fragments_enabled()) { return; } auto* presShell = mDocument ? mDocument->GetPresShell() : nullptr; if (!presShell) { return; } RefPtr targetTextSelection = presShell->GetCurrentSelection(SelectionType::eTargetText); if (!targetTextSelection) { return; } targetTextSelection->RemoveAllRanges(aRv); } already_AddRefed FragmentDirective::CreateTextDirective( nsRange& aRange) { RefPtr resultPromise = Promise::Create(mDocument->GetOwnerGlobal(), IgnoreErrors()); if (!resultPromise) { return nullptr; } if (!StaticPrefs::dom_text_fragments_create_text_fragment_enabled() || !StaticPrefs::dom_text_fragments_enabled()) { TEXT_FRAGMENT_LOG("Creating text fragments is disabled."); resultPromise->MaybeResolve(JS::NullHandleValue); return resultPromise.forget(); } if (aRange.Collapsed()) { TEXT_FRAGMENT_LOG("Collapsed range. Nothing to do here..."); resultPromise->MaybeResolve(JS::NullHandleValue); return resultPromise.forget(); } const TimeStamp start = TimeStamp::Now(); Result textDirective = TextDirectiveCreator::CreateTextDirectiveFromRange(*mDocument, &aRange); if (textDirective.isOk()) { nsCString textDirectiveString = textDirective.unwrap(); if (textDirectiveString.IsEmpty()) { mDocument->SetUseCounter(eUseCounter_custom_TextDirectiveNotCreated); resultPromise->MaybeResolve(JS::NullHandleValue); } else { resultPromise->MaybeResolve(std::move(textDirectiveString)); } } else { ErrorResult rv = textDirective.unwrapErr(); resultPromise->MaybeReject(std::move(rv)); } glean::dom_textfragment::create_directive.AccumulateRawDuration( TimeStamp::Now() - start); return resultPromise.forget(); } } // namespace mozilla::dom