commit 57912b1d826ebd7207cdf746f284f8ae4222648c parent 7f84b85eb86a2b4d32e588447b450c720de11bbe Author: Edgar Chen <echen@mozilla.com> Date: Fri, 12 Dec 2025 10:13:36 +0000 Bug 1998195 - Expose execCommand("paste") with a pop-up menu to web content; r=smaug,masayuki This patch exposes the execCommand("paste") to web content with the same security model as async Clipboard API. That is, a paste contextmenu will be shown to request user confirmation if the clipboard data originated from cross-origin content. Differential Revision: https://phabricator.services.mozilla.com/D274490 Diffstat:
20 files changed, 1577 insertions(+), 327 deletions(-)
diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp @@ -5736,6 +5736,22 @@ Document::AutoRunningExecCommandMarker::AutoRunningExecCommandMarker( } } +/** + * Returns true if calling execCommand with 'paste' arguments is allowed for the + * given subject principal. These are only allowed if the user initiated them + * (like with a mouse-click or key press). + */ +static bool IsExecCommandPasteAllowed(Document* aDocument, + nsIPrincipal& aSubjectPrincipal) { + if (StaticPrefs::dom_execCommand_paste_enabled() && aDocument && + aDocument->HasValidTransientUserGestureActivation()) { + return true; + } + + return nsContentUtils::PrincipalHasPermission(aSubjectPrincipal, + nsGkAtoms::clipboardRead); +} + bool Document::ExecCommand(const nsAString& aHTMLCommandName, bool aShowUI, const TrustedHTMLOrString& aValue, nsIPrincipal& aSubjectPrincipal, ErrorResult& aRv) { @@ -5799,8 +5815,14 @@ bool Document::ExecCommand(const nsAString& aHTMLCommandName, bool aShowUI, return false; } } else if (commandData.IsPasteCommand()) { - if (!nsContentUtils::PrincipalHasPermission(aSubjectPrincipal, - nsGkAtoms::clipboardRead)) { + if (!IsExecCommandPasteAllowed(this, aSubjectPrincipal)) { + if (StaticPrefs::dom_execCommand_paste_enabled()) { + // We rejected the command because it was not performed with a valid + // user activation; therefore, we report the error to the console. + nsContentUtils::ReportToConsole(nsIScriptError::warningFlag, "DOM"_ns, + this, nsContentUtils::eDOM_PROPERTIES, + "ExecCommandPasteDeniedNotInputDriven"); + } return false; } } @@ -5975,8 +5997,7 @@ bool Document::QueryCommandEnabled(const nsAString& aHTMLCommandName, } if (commandData.IsPasteCommand() && - !nsContentUtils::PrincipalHasPermission(aSubjectPrincipal, - nsGkAtoms::clipboardRead)) { + !IsExecCommandPasteAllowed(this, aSubjectPrincipal)) { return false; } @@ -6191,12 +6212,12 @@ bool Document::QueryCommandSupported(const nsAString& aHTMLCommandName, } // Gecko technically supports all the clipboard commands including - // cut/copy/paste, but non-privileged content will be unable to call - // paste, and depending on the pref "dom.allow_cut_copy", cut and copy - // may also be disallowed to be called from non-privileged content. - // For that reason, we report the support status of corresponding - // command accordingly. + // cut/copy/paste, and depending on the pref "dom.allow_cut_copy", cut and + // copy may also be disallowed to be called from non-privileged content. For + // that reason, we report the support status of corresponding command + // accordingly. if (commandData.IsPasteCommand() && + !StaticPrefs::dom_execCommand_paste_enabled() && !nsContentUtils::PrincipalHasPermission(aSubjectPrincipal, nsGkAtoms::clipboardRead)) { return false; diff --git a/dom/base/nsGlobalWindowCommands.cpp b/dom/base/nsGlobalWindowCommands.cpp @@ -16,6 +16,7 @@ #include "mozilla/StaticPrefs_accessibility.h" #include "mozilla/TextEditor.h" #include "mozilla/TextEvents.h" +#include "mozilla/dom/DataTransfer.h" #include "mozilla/dom/Document.h" #include "mozilla/dom/Selection.h" #include "mozilla/intl/WordBreaker.h" @@ -473,10 +474,35 @@ nsresult nsClipboardCommand::DoCommand(const nsACString& aCommandName, return eCopy; }(); + RefPtr<DataTransfer> dataTransfer; + if (ePaste == eventMessage) { + nsCOMPtr<nsIPrincipal> subjectPrincipal = + nsContentUtils::SubjectPrincipalOrSystemIfNativeCaller(); + MOZ_ASSERT(subjectPrincipal); + + // If we don't need to get user confirmation for clipboard access, we could + // just let nsCopySupport::FireClipboardEvent() to create DataTransfer + // instance synchronously for paste event. Otherwise, we need to spin the + // event loop to wait for the clipboard paste contextmenu to be shown and + // get user confirmation which are all handled in parent process before + // sending the paste event. + if (!nsContentUtils::PrincipalHasPermission(*subjectPrincipal, + nsGkAtoms::clipboardRead)) { + MOZ_DIAGNOSTIC_ASSERT(StaticPrefs::dom_execCommand_paste_enabled(), + "How did we get here?"); + // This will spin the event loop. + dataTransfer = DataTransfer::WaitForClipboardDataSnapshotAndCreate( + window, subjectPrincipal); + if (!dataTransfer) { + return NS_SUCCESS_DOM_NO_OPERATION; + } + } + } + bool actionTaken = false; - nsCopySupport::FireClipboardEvent(eventMessage, - Some(nsIClipboard::kGlobalClipboard), - presShell, nullptr, nullptr, &actionTaken); + nsCopySupport::FireClipboardEvent( + eventMessage, Some(nsIClipboard::kGlobalClipboard), presShell, nullptr, + dataTransfer, &actionTaken); return actionTaken ? NS_OK : NS_SUCCESS_DOM_NO_OPERATION; } diff --git a/dom/events/DataTransfer.cpp b/dom/events/DataTransfer.cpp @@ -12,6 +12,7 @@ #include "mozilla/ClipboardContentAnalysisChild.h" #include "mozilla/ClipboardReadRequestChild.h" #include "mozilla/Span.h" +#include "mozilla/SpinEventLoopUntil.h" #include "mozilla/StaticPrefs_dom.h" #include "mozilla/dom/BindingUtils.h" #include "mozilla/dom/ContentChild.h" @@ -56,6 +57,16 @@ namespace mozilla::dom { +// The order of the types matters. `kFileMime` needs to be one of the first +// two types. And the order should be the same as the types order defined in +// MandatoryDataTypesAsCStrings() for Clipboard API. +static constexpr nsLiteralCString kNonPlainTextExternalFormats[] = { + nsLiteralCString(kCustomTypesMime), nsLiteralCString(kFileMime), + nsLiteralCString(kHTMLMime), nsLiteralCString(kRTFMime), + nsLiteralCString(kURLMime), nsLiteralCString(kURLDataMime), + nsLiteralCString(kTextMime), nsLiteralCString(kPNGImageMime), + nsLiteralCString(kPDFJSMime)}; + NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(DataTransfer) NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(DataTransfer) @@ -181,6 +192,36 @@ DataTransfer::DataTransfer(nsISupports* aParent, EventMessage aEventMessage, "Failed to set given string to the DataTransfer object"); } +DataTransfer::DataTransfer(nsISupports* aParent, + nsIClipboard::ClipboardType aClipboardType, + nsIClipboardDataSnapshot* aClipboardDataSnapshot) + : mParent(aParent), + mEventMessage(ePaste), + mMode(ModeForEvent(ePaste)), + mClipboardType(Some(aClipboardType)) { + MOZ_ASSERT(aClipboardDataSnapshot); + + mClipboardDataSnapshot = aClipboardDataSnapshot; + mItems = new DataTransferItemList(this); + + AutoTArray<nsCString, std::size(kNonPlainTextExternalFormats)> flavors; + if (NS_FAILED(aClipboardDataSnapshot->GetFlavorList(flavors))) { + NS_WARNING("nsIClipboardDataSnapshot::GetFlavorList() failed"); + return; + } + + // Order is important for DataTransfer; ensure the returned list items follow + // the sequence specified in kNonPlainTextExternalFormats. + AutoTArray<nsCString, std::size(kNonPlainTextExternalFormats)> typesArray; + for (const auto& format : kNonPlainTextExternalFormats) { + if (flavors.Contains(format)) { + typesArray.AppendElement(format); + } + } + + CacheExternalData(typesArray, nsContentUtils::GetSystemPrincipal()); +} + DataTransfer::DataTransfer( nsISupports* aParent, EventMessage aEventMessage, const uint32_t aEffectAllowed, bool aCursorState, bool aIsExternal, @@ -235,6 +276,109 @@ JSObject* DataTransfer::WrapObject(JSContext* aCx, return DataTransfer_Binding::Wrap(aCx, this, aGivenProto); } +namespace { + +class ClipboardGetDataSnapshotCallback final + : public nsIClipboardGetDataSnapshotCallback { + public: + ClipboardGetDataSnapshotCallback(nsIGlobalObject* aGlobal, + nsIClipboard::ClipboardType aClipboardType) + : mGlobal(aGlobal), mClipboardType(aClipboardType) {} + + // This object will never be held by a cycle-collected object, so it doesn't + // need to be cycle-collected despite holding alive cycle-collected objects. + NS_DECL_ISUPPORTS + + // nsIClipboardGetDataSnapshotCallback + NS_IMETHOD OnSuccess( + nsIClipboardDataSnapshot* aClipboardDataSnapshot) override { + MOZ_ASSERT(aClipboardDataSnapshot); + mDataTransfer = MakeRefPtr<DataTransfer>( + ToSupports(mGlobal), mClipboardType, aClipboardDataSnapshot); + mComplete = true; + return NS_OK; + } + + NS_IMETHOD OnError(nsresult aResult) override { + mComplete = true; + return NS_OK; + } + + already_AddRefed<DataTransfer> TakeDataTransfer() { + MOZ_ASSERT(mComplete); + return mDataTransfer.forget(); + } + + bool IsComplete() const { return mComplete; } + + protected: + ~ClipboardGetDataSnapshotCallback() { + MOZ_ASSERT(!mDataTransfer); + MOZ_ASSERT(mComplete); + }; + + nsCOMPtr<nsIGlobalObject> mGlobal; + RefPtr<DataTransfer> mDataTransfer; + nsIClipboard::ClipboardType mClipboardType; + bool mComplete = false; +}; + +NS_IMPL_ISUPPORTS(ClipboardGetDataSnapshotCallback, + nsIClipboardGetDataSnapshotCallback) + +} // namespace + +// static +already_AddRefed<DataTransfer> +DataTransfer::WaitForClipboardDataSnapshotAndCreate( + nsPIDOMWindowOuter* aWindow, nsIPrincipal* aSubjectPrincipal) { + MOZ_ASSERT(aWindow); + MOZ_ASSERT(aSubjectPrincipal); + + nsCOMPtr<nsIClipboard> clipboardService = + do_GetService("@mozilla.org/widget/clipboard;1"); + if (!clipboardService) { + return nullptr; + } + + BrowsingContext* bc = aWindow->GetBrowsingContext(); + if (!bc) { + return nullptr; + } + + WindowContext* wc = bc->GetCurrentWindowContext(); + if (!wc) { + return nullptr; + } + + Document* doc = wc->GetExtantDoc(); + if (!doc) { + return nullptr; + } + + RefPtr<ClipboardGetDataSnapshotCallback> callback = + MakeRefPtr<ClipboardGetDataSnapshotCallback>( + doc->GetScopeObject(), nsIClipboard::kGlobalClipboard); + + AutoTArray<nsCString, std::size(kNonPlainTextExternalFormats)> types; + types.AppendElements( + Span<const nsLiteralCString>(kNonPlainTextExternalFormats)); + + nsresult rv = clipboardService->GetDataSnapshot( + types, nsIClipboard::kGlobalClipboard, wc, aSubjectPrincipal, callback); + if (NS_FAILED(rv)) { + return nullptr; + } + + if (!SpinEventLoopUntil( + "DataTransfer::WaitForClipboardDataSnapshotAndCreate"_ns, + [&]() { return callback->IsComplete(); })) { + return nullptr; + } + + return callback->TakeDataTransfer(); +} + void DataTransfer::SetDropEffect(const nsAString& aDropEffect) { // the drop effect can only be 'none', 'copy', 'move' or 'link'. for (uint32_t e = 0; e <= nsIDragService::DRAGDROP_ACTION_LINK; e++) { @@ -603,16 +747,6 @@ already_AddRefed<DataTransfer> DataTransfer::MozCloneForEvent( return dt.forget(); } -// The order of the types matters. `kFileMime` needs to be one of the first two -// types. And the order should be the same as the types order defined in -// MandatoryDataTypesAsCStrings() for Clipboard API. -static constexpr nsLiteralCString kNonPlainTextExternalFormats[] = { - nsLiteralCString(kCustomTypesMime), nsLiteralCString(kFileMime), - nsLiteralCString(kHTMLMime), nsLiteralCString(kRTFMime), - nsLiteralCString(kURLMime), nsLiteralCString(kURLDataMime), - nsLiteralCString(kTextMime), nsLiteralCString(kPNGImageMime), - nsLiteralCString(kPDFJSMime)}; - namespace { nsresult GetClipboardDataSnapshotWithContentAnalysisSync( const nsTArray<nsCString>& aFormats, diff --git a/dom/events/DataTransfer.h b/dom/events/DataTransfer.h @@ -107,6 +107,8 @@ class DataTransfer final : public nsISupports, public nsWrapperCache { nsITransferable* aTransferable); DataTransfer(nsISupports* aParent, EventMessage aEventMessage, const nsAString& aString); + DataTransfer(nsISupports* aParent, nsIClipboard::ClipboardType aClipboardType, + nsIClipboardDataSnapshot* aClipboardDataSnapshot); virtual JSObject* WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override; @@ -125,6 +127,18 @@ class DataTransfer final : public nsISupports, public nsWrapperCache { const GlobalObject& aGlobal); /** + * This creates a DataTransfer by calling nsIClipboard::GetDataSnapshot() to + * obtain an nsIClipboardDataSnapshot first, in order to trigger the security + * checks, i.e. showing a paste contextmenu to request user confirmation if + * the clipboard data originated from a cross-origin page. All of that is + * handled in the parent process, so we spin the event loop here to wait for + * the result. + */ + MOZ_CAN_RUN_SCRIPT + static already_AddRefed<DataTransfer> WaitForClipboardDataSnapshotAndCreate( + nsPIDOMWindowOuter* aWindow, nsIPrincipal* aSubjectPrincipal); + + /** * The actual effect that will be used, and should always be one of the * possible values of effectAllowed. * diff --git a/dom/events/test/clipboard/browser.toml b/dom/events/test/clipboard/browser.toml @@ -11,6 +11,20 @@ support-files = [ ["browser_document_command_paste.js"] +["browser_document_command_paste_contextmenu.js"] +skip-if = [ + "headless", # bug 1989339 +] + +["browser_document_command_paste_contextmenu_ext.js"] +skip-if = [ + "headless", # bug 1989339 +] + +["browser_document_command_paste_contextmenu_suppression.js"] + +["browser_document_command_paste_contextmenu_suppression_ext.js"] + ["browser_navigator_clipboard_clickjacking.js"] support-files = ["simple_navigator_clipboard_keydown.html"] run-if = [ @@ -66,6 +80,7 @@ support-files = [ skip-if = [ "headless", # bug 1989339 ] + ["browser_navigator_clipboard_read_ext.js"] support-files = ["simple_page_ext.html"] skip-if = [ diff --git a/dom/events/test/clipboard/browser_document_command_paste.js b/dom/events/test/clipboard/browser_document_command_paste.js @@ -8,295 +8,339 @@ const kContentFileUrl = kBaseUrlForContent + "simple_page_ext.html"; -beforeEach(async () => { - info("Write random text to clipboard"); - await promiseWritingRandomTextToClipboard(); +add_setup(async function init() { + await SpecialPowers.pushPrefEnv({ + // This to turn off the paste contextmenu for testing. + set: [["dom.events.testing.asyncClipboard", true]], + }); }); -describe("test paste comment", () => { - it(`called from system principal`, async () => { - document.clearUserGestureActivation(); - ok( - document.queryCommandSupported("paste"), - "Check if the 'paste' command is supported" - ); - - // Test without editing. - ok( - !document.queryCommandEnabled("paste"), - "Check if the 'paste' command is enabled without editing" - ); - ok( - !document.execCommand("paste"), - "Check if the 'paste' command is succeed without editing" - ); - - // Test with editing. - const textArea = document.createElement("textarea"); - document.body.appendChild(textArea); - textArea.textContent = "textarea text"; - textArea.setSelectionRange(0, textArea.value.length); - textArea.focus(); - ok( - document.queryCommandEnabled("paste"), - "Check if the 'paste' command is enabled when editing" - ); - ok( - document.execCommand("paste"), - "Check if the 'paste' command is succeed when editing" - ); - textArea.remove(); - }); +[true, false].forEach(aPrefValue => { + describe(`dom.execCommand.paste.enabled=${aPrefValue}`, () => { + it("set preference", async () => { + await SpecialPowers.pushPrefEnv({ + set: [["dom.execCommand.paste.enabled", aPrefValue]], + }); + }); - it(`called from web content`, async () => { - await BrowserTestUtils.withNewTab(kContentFileUrl, async browser => { - await SpecialPowers.spawn(browser, [], async () => { - const doc = Cu.waiveXrays(content.document); - ok( - !doc.queryCommandSupported("paste"), - `Check if the 'paste' command is supported` - ); + describe("test paste comment", () => { + beforeEach(async () => { + info("Write random text to clipboard"); + await promiseWritingRandomTextToClipboard(); + }); - // Test no user activation. - content.document.clearUserGestureActivation(); + it(`called from system principal`, async () => { + document.clearUserGestureActivation(); ok( - !doc.queryCommandEnabled("paste"), - "Check if the 'paste' command is enabled without user activation" - ); - ok( - !doc.execCommand("paste"), - "Check if the 'paste' command is succeed without user activation" + document.queryCommandSupported("paste"), + "Check if the 'paste' command is supported" ); - // Test with user activation. - content.document.notifyUserGestureActivation(); + // Test without editing. ok( - !doc.queryCommandEnabled("paste"), - "Check if the 'paste' command is enabled with user activation" + !document.queryCommandEnabled("paste"), + "Check if the 'paste' command is enabled without editing" ); ok( - !doc.execCommand("paste"), - "Check if the 'paste' command is succeed with user activation" + !document.execCommand("paste"), + "Check if the 'paste' command is succeed without editing" ); // Test with editing. - const textArea = content.document.createElement("textarea"); - content.document.body.appendChild(textArea); - textArea.textContent = "textarea text"; - textArea.setSelectionRange(0, textArea.value.length); - textArea.focus(); - - // Test no user activation. - content.document.clearUserGestureActivation(); - ok( - !doc.queryCommandEnabled("paste"), - "Check if the 'paste' command is enabled without user activation when editing" - ); - ok( - !doc.execCommand("paste"), - "Check if the 'paste' command is succeed without user activation when editing" - ); - - // Test with user activation. + const textArea = document.createElement("textarea"); + document.body.appendChild(textArea); textArea.textContent = "textarea text"; textArea.setSelectionRange(0, textArea.value.length); textArea.focus(); - content.document.notifyUserGestureActivation(); ok( - !doc.queryCommandEnabled("paste"), - "Check if the 'paste' command is enabled with user activation when editing" + document.queryCommandEnabled("paste"), + "Check if the 'paste' command is enabled when editing" ); ok( - !doc.execCommand("paste"), - "Check if the 'paste' command is succeed with user activation when editing" + document.execCommand("paste"), + "Check if the 'paste' command is succeed when editing" ); + textArea.remove(); }); - }); - }); - - [true, false].forEach(aPermission => { - describe(`extension ${aPermission ? "with" : "without"} clipboardRead permission`, () => { - const sharedScript = function () { - this.testPasteCommand = function () { - return [ - document.queryCommandSupported("paste"), - document.queryCommandEnabled("paste"), - document.execCommand("paste"), - ]; - }; - }; - it("called from content script", async () => { - const contentScript = function () { - document - .querySelector("button") - .addEventListener("click", function (e) { - browser.test.sendMessage("result", testPasteCommand()); - }); - browser.test.sendMessage("ready", testPasteCommand()); - }; - const extensionData = { - manifest: { - content_scripts: [ - { - js: ["sharedScript.js", "contentscript.js"], - matches: ["https://example.com/*"], - }, - ], - }, - files: { - "sharedScript.js": sharedScript, - "contentscript.js": contentScript, - }, - }; - if (aPermission) { - extensionData.manifest.permissions = ["clipboardRead"]; - } - - // Load and start the extension. - const extension = ExtensionTestUtils.loadExtension(extensionData); - await extension.startup(); + it(`called from web content`, async () => { await BrowserTestUtils.withNewTab(kContentFileUrl, async browser => { - let [supported, enabled, succeed] = - await extension.awaitMessage("ready"); - is( - supported, - aPermission, - "Check if the 'paste' command is supported" - ); - is( - enabled, - aPermission, - "Check if the 'paste' command is enabled without user activation" - ); - is( - succeed, - aPermission, - "Check if the 'paste' command is succeed without user activation" - ); + await SpecialPowers.spawn(browser, [aPrefValue], async aPrefValue => { + const doc = Cu.waiveXrays(content.document); + is( + doc.queryCommandSupported("paste"), + aPrefValue, + `Check if the 'paste' command is supported` + ); - // Click on the content to trigger user activation. - promiseClickContentElement(browser, "btn"); - [supported, enabled, succeed] = - await extension.awaitMessage("result"); - is( - enabled, - aPermission, - "Check if the 'paste' command is enabled with user activation" - ); - is( - succeed, - aPermission, - "Check if the 'paste' command is succeed with user activation" - ); - }); - await extension.unload(); - }); + // Test no user activation. + content.document.clearUserGestureActivation(); + ok( + !doc.queryCommandEnabled("paste"), + "Check if the 'paste' command is enabled without user activation" + ); + ok( + !doc.execCommand("paste"), + "Check if the 'paste' command is succeed without user activation" + ); - it("called from content script when editing", async () => { - const contentScript = function () { - const textArea = document.createElement("textarea"); - document.body.appendChild(textArea); - const testPasteCommandWhenEditing = function () { - // Start editing. + // Test with user activation. + content.document.notifyUserGestureActivation(); + is( + doc.queryCommandEnabled("paste"), + aPrefValue, + "Check if the 'paste' command is enabled with user activation" + ); + is( + doc.execCommand("paste"), + aPrefValue, + "Check if the 'paste' command is succeed with user activation" + ); + + // Test with editing. + const textArea = content.document.createElement("textarea"); + content.document.body.appendChild(textArea); textArea.textContent = "textarea text"; textArea.setSelectionRange(0, textArea.value.length); textArea.focus(); - return testPasteCommand(); - }; - document - .querySelector("button") - .addEventListener("click", function (e) { - browser.test.sendMessage("result", testPasteCommandWhenEditing()); - }); - browser.test.sendMessage("ready", testPasteCommandWhenEditing()); - }; - const extensionData = { - manifest: { - content_scripts: [ - { - js: ["sharedScript.js", "contentscript.js"], - matches: ["https://example.com/*"], - }, - ], - }, - files: { - "sharedScript.js": sharedScript, - "contentscript.js": contentScript, - }, - }; - if (aPermission) { - extensionData.manifest.permissions = ["clipboardRead"]; - } - // Load and start the extension. - const extension = ExtensionTestUtils.loadExtension(extensionData); - await extension.startup(); - await BrowserTestUtils.withNewTab(kContentFileUrl, async browser => { - let [supported, enabled, succeed] = - await extension.awaitMessage("ready"); - is( - supported, - aPermission, - "Check if the 'paste' command is supported" - ); - is( - enabled, - aPermission, - "Check if the 'paste' command is enabled without user activation" - ); - is( - succeed, - aPermission, - "Check if the 'paste' command is succeed without user activation" - ); + // Test no user activation. + content.document.clearUserGestureActivation(); + ok( + !doc.queryCommandEnabled("paste"), + "Check if the 'paste' command is enabled without user activation when editing" + ); + ok( + !doc.execCommand("paste"), + "Check if the 'paste' command is succeed without user activation when editing" + ); - // Click on the content to trigger user activation. - promiseClickContentElement(browser, "btn"); - [supported, enabled, succeed] = - await extension.awaitMessage("result"); - is( - enabled, - aPermission, - "Check if the 'paste' command is enabled with user activation" - ); - is( - succeed, - aPermission, - "Check if the 'paste' command is succeed with user activation" - ); + // Test with user activation. + textArea.textContent = "textarea text"; + textArea.setSelectionRange(0, textArea.value.length); + textArea.focus(); + content.document.notifyUserGestureActivation(); + is( + doc.queryCommandEnabled("paste"), + aPrefValue, + "Check if the 'paste' command is enabled with user activation when editing" + ); + is( + doc.execCommand("paste"), + aPrefValue, + "Check if the 'paste' command is succeed with user activation when editing" + ); + }); }); - await extension.unload(); }); - it("called from background script", async () => { - const backgroundScript = function () { - browser.test.sendMessage("ready", testPasteCommand()); - }; - const extensionData = { - background: [sharedScript, backgroundScript], - }; - if (aPermission) { - extensionData.manifest = { - permissions: ["clipboardRead"], + [true, false].forEach(aPermission => { + describe(`extension ${aPermission ? "with" : "without"} clipboardRead permission`, () => { + const sharedScript = function () { + this.testPasteCommand = function () { + return [ + document.queryCommandSupported("paste"), + document.queryCommandEnabled("paste"), + document.execCommand("paste"), + ]; + }; }; - } - // Load and start the extension. - const extension = ExtensionTestUtils.loadExtension(extensionData); - await extension.startup(); - await BrowserTestUtils.withNewTab(kContentFileUrl, async browser => { - let [supported, enabled, succeed] = - await extension.awaitMessage("ready"); - is( - supported, - aPermission, - "Check if the 'paste' command is supported" - ); - is(enabled, aPermission, "Check if the 'paste' command is enabled"); - is(succeed, aPermission, "Check if the 'paste' command is succeed"); + it("called from content script", async () => { + const contentScript = function () { + document + .querySelector("button") + .addEventListener("click", function (e) { + browser.test.sendMessage("result", testPasteCommand()); + }); + browser.test.sendMessage("ready", testPasteCommand()); + }; + const extensionData = { + manifest: { + content_scripts: [ + { + js: ["sharedScript.js", "contentscript.js"], + matches: ["https://example.com/*"], + }, + ], + }, + files: { + "sharedScript.js": sharedScript, + "contentscript.js": contentScript, + }, + }; + if (aPermission) { + extensionData.manifest.permissions = ["clipboardRead"]; + } + + // Load and start the extension. + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async browser => { + let [supported, enabled, succeed] = + await extension.awaitMessage("ready"); + is( + supported, + aPrefValue || aPermission, + "Check if the 'paste' command is supported" + ); + + // Test no user activation. + is( + enabled, + aPermission, + "Check if the 'paste' command is enabled without user activation" + ); + is( + succeed, + aPermission, + "Check if the 'paste' command is succeed without user activation" + ); + + // Click on the content to trigger user activation. + promiseClickContentElement(browser, "btn"); + [supported, enabled, succeed] = + await extension.awaitMessage("result"); + is( + enabled, + aPrefValue || aPermission, + "Check if the 'paste' command is enabled with user activation" + ); + is( + succeed, + aPrefValue || aPermission, + "Check if the 'paste' command is succeed with user activation" + ); + } + ); + await extension.unload(); + }); + + it("called from content script when editing", async () => { + const contentScript = function () { + const textArea = document.createElement("textarea"); + document.body.appendChild(textArea); + const testPasteCommandWhenEditing = function () { + // Start editing. + textArea.textContent = "textarea text"; + textArea.setSelectionRange(0, textArea.value.length); + textArea.focus(); + return testPasteCommand(); + }; + document + .querySelector("button") + .addEventListener("click", function (e) { + browser.test.sendMessage( + "result", + testPasteCommandWhenEditing() + ); + }); + browser.test.sendMessage("ready", testPasteCommandWhenEditing()); + }; + const extensionData = { + manifest: { + content_scripts: [ + { + js: ["sharedScript.js", "contentscript.js"], + matches: ["https://example.com/*"], + }, + ], + }, + files: { + "sharedScript.js": sharedScript, + "contentscript.js": contentScript, + }, + }; + if (aPermission) { + extensionData.manifest.permissions = ["clipboardRead"]; + } + + // Load and start the extension. + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async browser => { + let [supported, enabled, succeed] = + await extension.awaitMessage("ready"); + is( + supported, + aPrefValue || aPermission, + "Check if the 'paste' command is supported" + ); + is( + enabled, + aPermission, + "Check if the 'paste' command is enabled without user activation" + ); + is( + succeed, + aPermission, + "Check if the 'paste' command is succeed without user activation" + ); + + // Click on the content to trigger user activation. + promiseClickContentElement(browser, "btn"); + [supported, enabled, succeed] = + await extension.awaitMessage("result"); + is( + enabled, + aPrefValue || aPermission, + "Check if the 'paste' command is enabled with user activation" + ); + is( + succeed, + aPrefValue || aPermission, + "Check if the 'paste' command is succeed with user activation" + ); + } + ); + await extension.unload(); + }); + + it("called from background script", async () => { + const backgroundScript = function () { + browser.test.sendMessage("ready", testPasteCommand()); + }; + const extensionData = { + background: [sharedScript, backgroundScript], + }; + if (aPermission) { + extensionData.manifest = { + permissions: ["clipboardRead"], + }; + } + + // Load and start the extension. + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async browser => { + let [supported, enabled, succeed] = + await extension.awaitMessage("ready"); + is( + supported, + aPrefValue || aPermission, + "Check if the 'paste' command is supported" + ); + is( + enabled, + aPermission, + "Check if the 'paste' command is enabled" + ); + is( + succeed, + aPermission, + "Check if the 'paste' command is succeed" + ); + } + ); + await extension.unload(); + }); }); - await extension.unload(); }); }); }); diff --git a/dom/events/test/clipboard/browser_document_command_paste_contextmenu.js b/dom/events/test/clipboard/browser_document_command_paste_contextmenu.js @@ -0,0 +1,284 @@ +/* -*- Mode: JavaScript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +"use strict"; + +const kContentFileUrl = kBaseUrlForContent + "simple_page_ext.html"; + +function promiseExecCommandPaste(aBrowser) { + return SpecialPowers.spawn(aBrowser, [], () => { + let clipboardData = null; + content.document.addEventListener( + "paste", + e => { + clipboardData = e.clipboardData.getData("text/plain"); + }, + { once: true } + ); + + content.document.notifyUserGestureActivation(); + const execCommandResult = Cu.waiveXrays(content.document).execCommand( + "paste" + ); + + return { execCommandResult, clipboardData }; + }); +} + +function execCommandPasteWithoutWait(aBrowser) { + return SpecialPowers.spawn(aBrowser, [], () => { + SpecialPowers.executeSoon(() => { + content.document.notifyUserGestureActivation(); + const execCommandResult = Cu.waiveXrays(content.document).execCommand( + "paste" + ); + }); + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["test.events.async.enabled", true], + // Disable the paste contextmenu delay to make the test run faster. + ["security.dialog_enable_delay", 0], + ], + }); +}); + +kPasteCommandTests.forEach(test => { + describe(test.description, () => { + it("Accepting paste contextmenu", async () => { + info(`Randomized text to avoid overlappings with other tests`); + const clipboardText = await promiseWritingRandomTextToClipboard(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste")`); + const pasteCommandResult = promiseExecCommandPaste(aBrowser); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info(`Click paste context menu`); + const pasteButtonIsHidden = promisePasteButtonIsHidden(); + await promiseClickPasteButton(); + await pasteButtonIsHidden; + + const { execCommandResult, clipboardData } = await pasteCommandResult; + ok(execCommandResult, `execCommand("paste") should be succeed`); + is(clipboardData, clipboardText, `Check clipboard data`); + + if (test.additionalCheckFunc) { + info(`Additional checks`); + await test.additionalCheckFunc(aBrowser, clipboardText); + } + } + ); + }); + + it("Dismissing paste contextmenu", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste")`); + const pasteCommandResult = promiseExecCommandPaste(aBrowser); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info(`Dismiss paste context menu`); + const pasteButtonIsHidden = promisePasteButtonIsHidden(); + await promiseDismissPasteButton(); + await pasteButtonIsHidden; + + const { execCommandResult, clipboardData } = await pasteCommandResult; + ok(!execCommandResult, `execCommand("paste") should not be succeed`); + is(clipboardData, null, `Check clipboard data`); + + if (test.additionalCheckFunc) { + info(`Additional checks`); + await test.additionalCheckFunc(aBrowser, ""); + } + } + ); + }); + + it("Tab close", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + let pasteButtonIsHidden; + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste")`); + execCommandPasteWithoutWait(aBrowser); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + pasteButtonIsHidden = promisePasteButtonIsHidden(); + info("Close tab"); + } + ); + + await pasteButtonIsHidden; + }); + + it("Tab switch", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste")`); + execCommandPasteWithoutWait(aBrowser); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info("Switch tab"); + let pasteButtonIsHidden = promisePasteButtonIsHidden(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info(`Wait for paste context menu is hidden`); + await pasteButtonIsHidden; + + BrowserTestUtils.removeTab(tab); + } + ); + }); + + it("Window switch", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste")`); + execCommandPasteWithoutWait(aBrowser); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info("Switch browser window"); + let pasteButtonIsHidden = promisePasteButtonIsHidden(); + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + info(`Wait for paste context menu is hidden`); + await pasteButtonIsHidden; + + await BrowserTestUtils.closeWindow(newWin); + } + ); + }); + + it("Tab navigate", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste")`); + execCommandPasteWithoutWait(aBrowser); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info("Navigate tab"); + let pasteButtonIsHidden = promisePasteButtonIsHidden(); + aBrowser.loadURI(Services.io.newURI("https://example.com/"), { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }); + + info(`Wait for paste context menu is hidden`); + await pasteButtonIsHidden; + } + ); + }); + + it("Tab reload", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste")`); + execCommandPasteWithoutWait(aBrowser); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info("Reload tab"); + let pasteButtonIsHidden = promisePasteButtonIsHidden(); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + info(`Wait for paste context menu is hidden`); + await pasteButtonIsHidden; + } + ); + }); + }); +}); diff --git a/dom/events/test/clipboard/browser_document_command_paste_contextmenu_ext.js b/dom/events/test/clipboard/browser_document_command_paste_contextmenu_ext.js @@ -0,0 +1,366 @@ +/* -*- Mode: JavaScript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +"use strict"; + +const kContentFileUrl = kBaseUrlForContent + "simple_page_ext.html"; + +async function promiseExecCommandPasteFromExtension(aBrowser, aExtension) { + await SpecialPowers.spawn(aBrowser, [], async () => { + content.document.notifyUserGestureActivation(); + }); + + aExtension.sendMessage("paste"); + return await aExtension.awaitMessage("result"); +} + +async function execCommandPasteFromExtensionWithoutResult( + aBrowser, + aExtension +) { + await SpecialPowers.spawn(aBrowser, [], async () => { + content.document.notifyUserGestureActivation(); + }); + + aExtension.sendMessage("pasteWithoutResult"); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["test.events.async.enabled", true], + // Disable the paste contextmenu delay to make the test run faster. + ["security.dialog_enable_delay", 0], + ], + }); +}); + +kPasteCommandTests.forEach(test => { + describe(test.description, () => { + const contentScript = function () { + browser.test.onMessage.addListener(async aMsg => { + if (aMsg === "paste") { + let clipboardData = null; + document.addEventListener( + "paste", + e => { + clipboardData = e.clipboardData.getData("text/plain"); + }, + { once: true } + ); + + const execCommandResult = document.execCommand("paste"); + browser.test.sendMessage("result", { + execCommandResult, + clipboardData, + }); + } else if (aMsg === "pasteWithoutResult") { + setTimeout(() => { + document.execCommand("paste"); + }, 0); + } + }); + }; + const extensionData = { + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["https://example.com/*"], + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + }; + + it("Accepting paste contextmenu", async () => { + info(`Randomized text to avoid overlappings with other tests`); + const clipboardText = await promiseWritingRandomTextToClipboard(); + + info(`Load and start the extension`); + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste") from extension`); + const pasteCommandResult = promiseExecCommandPasteFromExtension( + aBrowser, + extension + ); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info(`Click paste context menu`); + const pasteButtonIsHidden = promisePasteButtonIsHidden(); + await promiseClickPasteButton(); + await pasteButtonIsHidden; + + const { execCommandResult, clipboardData } = await pasteCommandResult; + ok(execCommandResult, `execCommand("paste") should be succeed`); + is(clipboardData, clipboardText, `Check clipboard data`); + + if (test.additionalCheckFunc) { + info(`Additional checks`); + await test.additionalCheckFunc(aBrowser, clipboardText); + } + } + ); + + info(`Unload extension`); + await extension.unload(); + }); + + it("Dismissing paste contextmenu", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + info(`Load and start the extension`); + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste") from extension`); + const pasteCommandResult = promiseExecCommandPasteFromExtension( + aBrowser, + extension + ); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info(`Dismiss paste context menu`); + const pasteButtonIsHidden = promisePasteButtonIsHidden(); + await promiseDismissPasteButton(); + await pasteButtonIsHidden; + + const { execCommandResult, clipboardData } = await pasteCommandResult; + ok(!execCommandResult, `execCommand("paste") should not be succeed`); + is(clipboardData, null, `Check clipboard data`); + + if (test.additionalCheckFunc) { + info(`Additional checks`); + await test.additionalCheckFunc(aBrowser, ""); + } + } + ); + + info(`Unload extension`); + await extension.unload(); + }); + + it("Tab close", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + info(`Load and start the extension`); + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let pasteButtonIsHidden; + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste") from extension`); + execCommandPasteFromExtensionWithoutResult(aBrowser, extension); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + pasteButtonIsHidden = promisePasteButtonIsHidden(); + info("Close tab"); + } + ); + + await pasteButtonIsHidden; + + info(`Unload extension`); + await extension.unload(); + }); + + it("Tab switch", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + info(`Load and start the extension`); + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste") from extension`); + execCommandPasteFromExtensionWithoutResult(aBrowser, extension); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info("Switch tab"); + let pasteButtonIsHidden = promisePasteButtonIsHidden(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info(`Wait for paste context menu is hidden`); + await pasteButtonIsHidden; + + BrowserTestUtils.removeTab(tab); + } + ); + + info(`Unload extension`); + await extension.unload(); + }); + + it("Window switch", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + info(`Load and start the extension`); + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste") from extension`); + execCommandPasteFromExtensionWithoutResult(aBrowser, extension); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info("Switch browser window"); + let pasteButtonIsHidden = promisePasteButtonIsHidden(); + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + info(`Wait for paste context menu is hidden`); + await pasteButtonIsHidden; + + await BrowserTestUtils.closeWindow(newWin); + } + ); + + info(`Unload extension`); + await extension.unload(); + }); + + it("Tab navigate", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + info(`Load and start the extension`); + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste") from extension`); + execCommandPasteFromExtensionWithoutResult(aBrowser, extension); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info("Navigate tab"); + let pasteButtonIsHidden = promisePasteButtonIsHidden(); + aBrowser.loadURI(Services.io.newURI("https://example.com/"), { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }); + + info(`Wait for paste context menu is hidden`); + await pasteButtonIsHidden; + } + ); + + info(`Unload extension`); + await extension.unload(); + }); + + it("Tab reload", async () => { + info(`Randomized text to avoid overlappings with other tests`); + await promiseWritingRandomTextToClipboard(); + + info(`Load and start the extension`); + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + const pasteButtonIsShown = promisePasteButtonIsShown(); + + info(`Trigger execCommand("paste") from extension`); + execCommandPasteFromExtensionWithoutResult(aBrowser, extension); + + info(`Wait for paste context menu is shown`); + await pasteButtonIsShown; + + info("Reload tab"); + let pasteButtonIsHidden = promisePasteButtonIsHidden(); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + info(`Wait for paste context menu is hidden`); + await pasteButtonIsHidden; + } + ); + + info(`Unload extension`); + await extension.unload(); + }); + }); +}); diff --git a/dom/events/test/clipboard/browser_document_command_paste_contextmenu_suppression.js b/dom/events/test/clipboard/browser_document_command_paste_contextmenu_suppression.js @@ -0,0 +1,147 @@ +/* -*- Mode: JavaScript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +"use strict"; + +const kContentFileUrl = kBaseUrlForContent + "simple_page_ext.html"; +const kIsMac = navigator.platform.indexOf("Mac") > -1; + +const kSuppressionTests = [ + { + description: "Trigger paste command from keyboard shortcut", + triggerPasteFun: async () => { + await EventUtils.synthesizeAndWaitKey( + "v", + kIsMac ? { accelKey: true } : { ctrlKey: true } + ); + }, + }, + { + description: "Trigger paste command", + triggerPasteFun: async aBrowser => { + await SpecialPowers.spawn(aBrowser, [], async () => { + await SpecialPowers.doCommand(content.window, "cmd_paste"); + }); + }, + }, +]; + +async function promiseClipboardDataFromPasteEvent(aBrowser, aTriggerPasteFun) { + const promisePasteEvent = SpecialPowers.spawn(aBrowser, [], () => { + return new Promise(resolve => { + content.document.addEventListener( + "paste", + e => { + const clipboardData = e.clipboardData.getData("text/plain"); + resolve(clipboardData); + }, + { once: true } + ); + }); + }); + // Enuse the event listener is registered on remote target. + await SpecialPowers.spawn(aBrowser, [], async () => { + await new Promise(resolve => { + SpecialPowers.executeSoon(resolve); + }); + }); + const result = await aTriggerPasteFun(aBrowser); + const clipboardData = await promisePasteEvent; + return { result, clipboardData }; +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["test.events.async.enabled", true], + // Disable the paste contextmenu delay to make the test run faster. + ["security.dialog_enable_delay", 0], + ], + }); + + // Paste contextmenu should not be shown during the test. + let listener = function (e) { + if (e.target.getAttribute("id") == kPasteMenuPopupId) { + ok(false, "paste contextmenu should not be shown"); + } + }; + document.addEventListener("popupshown", listener); + + registerCleanupFunction(() => { + document.removeEventListener("popupshown", listener); + }); +}); + +kPasteCommandTests.forEach(test => { + describe(test.description, () => { + it("Same-origin data", async () => { + const clipboardText = "X" + Math.random(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + info(`Write clipboard data`); + await SpecialPowers.spawn(aBrowser, [clipboardText], async text => { + content.document.notifyUserGestureActivation(); + return content.eval(`navigator.clipboard.writeText("${text}");`); + }); + + info(`Trigger execCommand("paste")`); + const { result, clipboardData } = + await promiseClipboardDataFromPasteEvent(aBrowser, async () => { + return SpecialPowers.spawn(aBrowser, [], () => { + content.document.notifyUserGestureActivation(); + return Cu.waiveXrays(content.document).execCommand("paste"); + }); + }); + ok(result, `execCommand("paste") should be succeed`); + is(clipboardData, clipboardText, `Check clipboard data`); + + if (test.additionalCheckFunc) { + info(`Additional checks`); + await test.additionalCheckFunc(aBrowser, clipboardText); + } + } + ); + }); + + describe("cross-origin data", () => { + kSuppressionTests.forEach(subTest => { + it(subTest.description, async () => { + const clipboardText = await promiseWritingRandomTextToClipboard(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + info(`Trigger paste command`); + const { clipboardData } = + await promiseClipboardDataFromPasteEvent( + aBrowser, + subTest.triggerPasteFun + ); + is(clipboardData, clipboardText, `Check clipboard data`); + + if (test.additionalCheckFunc) { + info(`Additional checks`); + await test.additionalCheckFunc(aBrowser, clipboardText); + } + } + ); + }); + }); + }); + }); +}); diff --git a/dom/events/test/clipboard/browser_document_command_paste_contextmenu_suppression_ext.js b/dom/events/test/clipboard/browser_document_command_paste_contextmenu_suppression_ext.js @@ -0,0 +1,150 @@ +/* -*- Mode: JavaScript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +"use strict"; + +const kContentFileUrl = kBaseUrlForContent + "simple_page_ext.html"; + +async function promiseExecCommandPasteFromExtension(aBrowser, aExtension) { + await SpecialPowers.spawn(aBrowser, [], async () => { + content.document.notifyUserGestureActivation(); + }); + + aExtension.sendMessage("paste"); + return await aExtension.awaitMessage("result"); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["test.events.async.enabled", true], + // Disable the paste contextmenu delay to make the test run faster. + ["security.dialog_enable_delay", 0], + ], + }); + + // Paste contextmenu should not be shown during the test. + let listener = function (e) { + if (e.target.getAttribute("id") == kPasteMenuPopupId) { + ok(false, "paste contextmenu should not be shown"); + } + }; + document.addEventListener("popupshown", listener); + + registerCleanupFunction(() => { + document.removeEventListener("popupshown", listener); + }); +}); + +kPasteCommandTests.forEach(test => { + describe(test.description, () => { + const contentScript = function () { + browser.test.onMessage.addListener(async aMsg => { + if (aMsg === "paste") { + let clipboardData = null; + document.addEventListener( + "paste", + e => { + clipboardData = e.clipboardData.getData("text/plain"); + }, + { once: true } + ); + + const execCommandResult = document.execCommand("paste"); + browser.test.sendMessage("result", { + execCommandResult, + clipboardData, + }); + } + }); + }; + const extensionData = { + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["https://example.com/*"], + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + }; + + it("Same-origin data", async () => { + const clipboardText = "X" + Math.random(); + + info(`Load and start the extension`); + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + info(`Write clipboard data`); + await SpecialPowers.spawn(aBrowser, [clipboardText], async text => { + content.document.notifyUserGestureActivation(); + return content.eval(`navigator.clipboard.writeText("${text}");`); + }); + + info(`Trigger execCommand("paste") from extension`); + const { execCommandResult, clipboardData } = + await promiseExecCommandPasteFromExtension(aBrowser, extension); + ok(execCommandResult, `execCommand("paste") should be succeed`); + is(clipboardData, clipboardText, `Check clipboard data`); + + if (test.additionalCheckFunc) { + info(`Additional checks`); + await test.additionalCheckFunc(aBrowser, clipboardText); + } + } + ); + + info(`Unload extension`); + await extension.unload(); + }); + + it("Extension with clipboardRead permission", async () => { + info(`Randomized text to avoid overlappings with other tests`); + const clipboardText = await promiseWritingRandomTextToClipboard(); + + info(`Load and start the extension`); + extensionData.manifest.permissions = ["clipboardRead"]; + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + kContentFileUrl, + async function (aBrowser) { + if (test.setupFn) { + info(`Setup`); + await test.setupFn(aBrowser); + } + + info(`Trigger execCommand("paste") from extension`); + const { execCommandResult, clipboardData } = + await promiseExecCommandPasteFromExtension(aBrowser, extension); + ok(execCommandResult, `execCommand("paste") should be succeed`); + is(clipboardData, clipboardText, `Check clipboard data`); + + if (test.additionalCheckFunc) { + info(`Additional checks`); + await test.additionalCheckFunc(aBrowser, clipboardText); + } + } + ); + + info(`Unload extension`); + await extension.unload(); + }); + }); +}); diff --git a/dom/events/test/clipboard/head.js b/dom/events/test/clipboard/head.js @@ -13,6 +13,43 @@ const kBaseUrlForContent = getRootDirectory(gTestPath).replace( "https://example.com" ); +const kPasteCommandTests = [ + { description: "Test paste command without editing" }, + { + description: "Test paste command on <textarea>", + setupFn: aBrowser => { + return SpecialPowers.spawn(aBrowser, [], () => { + const textarea = content.document.createElement("textarea"); + content.document.body.appendChild(textarea); + textarea.focus(); + }); + }, + additionalCheckFunc: (aBrowser, aClipboardData) => { + return SpecialPowers.spawn(aBrowser, [aClipboardData], aClipboardData => { + const textarea = content.document.querySelector("textarea"); + is(textarea.value, aClipboardData, "check <textarea> value"); + }); + }, + }, + { + description: "Test paste command on <div contenteditable=true>", + setupFn: aBrowser => { + return SpecialPowers.spawn(aBrowser, [], () => { + const div = content.document.createElement("div"); + div.setAttribute("contenteditable", "true"); + content.document.body.appendChild(div); + div.focus(); + }); + }, + additionalCheckFunc: (aBrowser, aClipboardData) => { + return SpecialPowers.spawn(aBrowser, [aClipboardData], aClipboardData => { + const div = content.document.querySelector("div"); + is(div.innerText, aClipboardData, "check contenteditable innerText"); + }); + }, + }, +]; + Services.scriptloader.loadSubScript( "chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_native_event_utils.js", this @@ -50,10 +87,12 @@ function promisePasteButtonIsShown() { ok(true, "Witnessed 'popupshown' event for 'Paste' button."); const pasteButton = document.getElementById(kPasteMenuItemId); - ok( - pasteButton.disabled, - "Paste button should be shown with disabled by default" - ); + if (Services.prefs.getIntPref("security.dialog_enable_delay") > 0) { + ok( + pasteButton.disabled, + "Paste button should be shown with disabled by default" + ); + } await BrowserTestUtils.waitForMutationCondition( pasteButton, { attributeFilter: ["disabled"] }, diff --git a/dom/locales/en-US/chrome/dom/dom.properties b/dom/locales/en-US/chrome/dom/dom.properties @@ -221,7 +221,10 @@ ServiceWorkerPostMessageStorageError=The ServiceWorker for scope ‘%S’ failed ServiceWorkerGraceTimeoutTermination=Terminating ServiceWorker for scope ‘%1$S’ with pending waitUntil/respondWith promises because of grace timeout. # LOCALIZATION NOTE (ServiceWorkerNoFetchHandler): Do not translate "Fetch". ServiceWorkerNoFetchHandler=Fetch event handlers must be added during the worker script’s initial evaluation. +# LOCALIZATION NOTE: Do not translate "document.execCommand(‘cut’/‘copy’)". ExecCommandCutCopyDeniedNotInputDriven=document.execCommand(‘cut’/‘copy’) was denied because it was not called from inside a short running user-generated event handler. +# LOCALIZATION NOTE: Do not translate "document.execCommand(‘paste’)". +ExecCommandPasteDeniedNotInputDriven=document.execCommand(‘paste’) was denied because it was not called from inside a short running user-generated event handler. ManifestIdIsInvalid=The id member did not resolve to a valid URL. ManifestIdNotSameOrigin=The id member must have the same origin as the start_url member. ManifestShouldBeObject=Manifest should be an object. diff --git a/dom/tests/mochitest/general/test_bug1161721.html b/dom/tests/mochitest/general/test_bug1161721.html @@ -19,13 +19,19 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1161721 <pre id="test"> <script type="application/javascript"> - ok(!document.queryCommandSupported("paste"), "Paste isn't supported in non-privilged JavaScript"); - ok(document.queryCommandSupported("copy"), "Copy is supported in non-privilged JavaScript"); - ok(document.queryCommandSupported("cut"), "Cut is supported in non-privilged JavaScript"); + add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.execCommand.paste.enabled", false]], + }); - ok(SpecialPowers.wrap(document).queryCommandSupported("paste"), "Paste is supported in privilged JavaScript"); - ok(SpecialPowers.wrap(document).queryCommandSupported("copy"), "Copy is supported in privilged JavaScript"); - ok(SpecialPowers.wrap(document).queryCommandSupported("cut"), "Cut is supported in privilged JavaScript"); + ok(!document.queryCommandSupported("paste"), "Paste isn't supported in non-privileged JavaScript"); + ok(document.queryCommandSupported("copy"), "Copy is supported in non-privileged JavaScript"); + ok(document.queryCommandSupported("cut"), "Cut is supported in non-privileged JavaScript"); + + ok(SpecialPowers.wrap(document).queryCommandSupported("paste"), "Paste is supported in privileged JavaScript"); + ok(SpecialPowers.wrap(document).queryCommandSupported("copy"), "Copy is supported in privileged JavaScript"); + ok(SpecialPowers.wrap(document).queryCommandSupported("cut"), "Cut is supported in privileged JavaScript"); + }); </script> </pre> </body> diff --git a/editor/libeditor/EditorBase.cpp b/editor/libeditor/EditorBase.cpp @@ -1928,7 +1928,6 @@ nsresult EditorBase::PasteAsAction(nsIClipboard::ClipboardType aClipboardType, // This method is not set up to pass back the new aDataTransfer // if it changes. If we need this in the future, we can change // aDataTransfer to be a RefPtr<DataTransfer>*. - MOZ_ASSERT(!aDataTransfer); AutoTrackDataTransferForPaste trackDataTransfer(*this, dataTransfer); ret = DispatchClipboardEventAndUpdateClipboard( diff --git a/editor/libeditor/EditorCommands.cpp b/editor/libeditor/EditorCommands.cpp @@ -11,6 +11,7 @@ #include "mozilla/HTMLEditor.h" #include "mozilla/Maybe.h" #include "mozilla/MozPromise.h" // for mozilla::detail::Any +#include "mozilla/dom/DataTransfer.h" #include "mozilla/dom/Document.h" #include "mozilla/dom/Selection.h" #include "nsCommandParams.h" @@ -432,9 +433,34 @@ bool PasteCommand::IsCommandEnabled(Command aCommand, nsresult PasteCommand::DoCommand(Command aCommand, EditorBase& aEditorBase, nsIPrincipal* aPrincipal) const { + RefPtr<DataTransfer> dataTransfer; + nsCOMPtr<nsIPrincipal> subjectPrincipal = + aPrincipal ? aPrincipal + : nsContentUtils::SubjectPrincipalOrSystemIfNativeCaller(); + MOZ_ASSERT(subjectPrincipal); + + // If we don't need to get user confirmation for clipboard access, we could + // just let EditorBase::PasteAsAction() to create DataTransfer instance + // synchronously for paste event. Otherwise, we need to spin the event loop to + // wait for the clipboard paste contextmenu to be shown and get user + // confirmation which are all handled in parent process before sending the + // paste event. + if (!nsContentUtils::PrincipalHasPermission(*subjectPrincipal, + nsGkAtoms::clipboardRead)) { + MOZ_DIAGNOSTIC_ASSERT(StaticPrefs::dom_execCommand_paste_enabled(), + "How did we get here?"); + // This will spin the event loop. + nsCOMPtr<nsPIDOMWindowOuter> window = aEditorBase.GetWindow(); + dataTransfer = DataTransfer::WaitForClipboardDataSnapshotAndCreate( + window, subjectPrincipal); + if (!dataTransfer) { + return NS_SUCCESS_DOM_NO_OPERATION; + } + } + nsresult rv = aEditorBase.PasteAsAction(nsIClipboard::kGlobalClipboard, EditorBase::DispatchPasteEvent::Yes, - nullptr, aPrincipal); + dataTransfer, aPrincipal); NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "EditorBase::PasteAsAction(nsIClipboard::" "kGlobalClipboard, DispatchPasteEvent::Yes) failed"); diff --git a/editor/libeditor/tests/browserscope/lib/richtext2/currentStatus.js b/editor/libeditor/tests/browserscope/lib/richtext2/currentStatus.js @@ -284,9 +284,9 @@ const knownFailures = { "Q-Proposed-INCREASEFONTSIZE_TEXT-1-dM": true, "Q-Proposed-INCREASEFONTSIZE_TEXT-1-body": true, "Q-Proposed-INCREASEFONTSIZE_TEXT-1-div": true, - "Q-Proposed-PASTE_TEXT-1-dM": true, - "Q-Proposed-PASTE_TEXT-1-body": true, - "Q-Proposed-PASTE_TEXT-1-div": true, + "Q-Proposed-PASTE_TEXT-1-dM": !SpecialPowers.getBoolPref("dom.execCommand.paste.enabled", false), + "Q-Proposed-PASTE_TEXT-1-body": !SpecialPowers.getBoolPref("dom.execCommand.paste.enabled", false), + "Q-Proposed-PASTE_TEXT-1-div": !SpecialPowers.getBoolPref("dom.execCommand.paste.enabled", false), "Q-Proposed-UNBOOKMARK_TEXT-1-dM": true, "Q-Proposed-UNBOOKMARK_TEXT-1-body": true, "Q-Proposed-UNBOOKMARK_TEXT-1-div": true, diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml @@ -3021,6 +3021,12 @@ value: true mirror: always +# Whether to expose execCommand("paste") to web content. +- name: dom.execCommand.paste.enabled + type: bool + value: true + mirror: always + # Whether to expose test interfaces of various sorts - name: dom.expose_test_interfaces type: bool diff --git a/testing/web-platform/meta/editing/other/exec-command-with-text-editor.tentative.html.ini b/testing/web-platform/meta/editing/other/exec-command-with-text-editor.tentative.html.ini @@ -13,12 +13,6 @@ [In <input type="text">, execCommand("copy", false, null), abc[\]d): execCommand() should return false] expected: FAIL - [In <input type="text">, execCommand("paste", false, null), a[\]c): The command should be supported] - expected: FAIL - - [In <input type="text">, execCommand("paste", false, null), a[\]c): The command should be enabled] - expected: FAIL - [In <input type="text">, execCommand("undo", false, null), a[b\]c): The command should be enabled] expected: FAIL @@ -64,12 +58,6 @@ [In <input type="text"> in contenteditable, execCommand("copy", false, null), abc[\]d): execCommand() should return false] expected: FAIL - [In <input type="text"> in contenteditable, execCommand("paste", false, null), a[\]c): The command should be supported] - expected: FAIL - - [In <input type="text"> in contenteditable, execCommand("paste", false, null), a[\]c): The command should be enabled] - expected: FAIL - [In <input type="text"> in contenteditable, execCommand("undo", false, null), a[b\]c): The command should be enabled] expected: FAIL @@ -115,12 +103,6 @@ [In <input type="password">, execCommand("copy", false, null), a[bc\]d): execCommand() should return true] expected: FAIL - [In <input type="password">, execCommand("paste", false, null), a[\]c): The command should be supported] - expected: FAIL - - [In <input type="password">, execCommand("paste", false, null), a[\]c): The command should be enabled] - expected: FAIL - [In <input type="password">, execCommand("undo", false, null), a[b\]c): The command should be enabled] expected: FAIL @@ -175,12 +157,6 @@ [In <input type="password"> in contenteditable, execCommand("copy", false, null), a[bc\]d): execCommand() should return true] expected: FAIL - [In <input type="password"> in contenteditable, execCommand("paste", false, null), a[\]c): The command should be supported] - expected: FAIL - - [In <input type="password"> in contenteditable, execCommand("paste", false, null), a[\]c): The command should be enabled] - expected: FAIL - [In <input type="password"> in contenteditable, execCommand("undo", false, null), a[b\]c): The command should be enabled] expected: FAIL @@ -215,12 +191,6 @@ [In <textarea>, execCommand("copy", false, null), abc[\]d): execCommand() should return false] expected: FAIL - [In <textarea>, execCommand("paste", false, null), a[\]c): The command should be supported] - expected: FAIL - - [In <textarea>, execCommand("paste", false, null), a[\]c): The command should be enabled] - expected: FAIL - [In <textarea>, execCommand("undo", false, null), a[b\]c): The command should be enabled] expected: FAIL @@ -269,12 +239,6 @@ [In <textarea> in contenteditable, execCommand("copy", false, null), abc[\]d): execCommand() should return false] expected: FAIL - [In <textarea> in contenteditable, execCommand("paste", false, null), a[\]c): The command should be supported] - expected: FAIL - - [In <textarea> in contenteditable, execCommand("paste", false, null), a[\]c): The command should be enabled] - expected: FAIL - [In <textarea> in contenteditable, execCommand("redo", false, null), a[b\]c): The command should be enabled] expected: FAIL diff --git a/testing/web-platform/meta/editing/other/exec-command-without-editable-element.tentative.html.ini b/testing/web-platform/meta/editing/other/exec-command-without-editable-element.tentative.html.ini @@ -23,3 +23,9 @@ [ParentDocument.execCommand(copy, false, null) with a\[b\]c: checking event on executed document] expected: FAIL + [ChildDocument.execCommand(paste, false, null) with a[b\]c: checking event on executed document] + expected: FAIL + + [ParentDocument.execCommand(paste, false, null) with a[b\]c: checking event on executed document] + expected: FAIL + diff --git a/testing/web-platform/tests/editing/other/exec-command-with-text-editor.tentative.html b/testing/web-platform/tests/editing/other/exec-command-with-text-editor.tentative.html @@ -137,7 +137,7 @@ async function runTest(aTarget, aDescription) { beforeinputExpected: null, inputExpected: null, }, {command: "paste", param: null, - value: "a[]c", expectedValue: "a[bc]c", + value: "a[]c", expectedValue: "abc[]c", expectedExecCommandResult: true, expectedCommandSupported: true, expectedCommandEnabled: true,