diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/clipboard.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/clipboard.rs index 70a05da403..99bb906f49 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/clipboard.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/clipboard.rs @@ -2,14 +2,12 @@ use super::super::{SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, SIMPLE_EVENT_TARGE use crate::web_api_interfaces; use crate::{ blob, - util::{get_private_value, set_private_value, throw_type_error, v8_string}, + util::{context_host_ptr_from_context_slot, get_private_value, throw_type_error, v8_string}, webidl, }; use anyhow::{Result, anyhow}; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; -const CLIPBOARD_ITEMS_SLOT: &str = "__moliClipboardItems"; -const CLIPBOARD_TEXT_SLOT: &str = "__moliClipboardText"; const CLIPBOARD_EVENT_LISTENERS_SLOT: &str = "__moliClipboardEventListeners"; const CLIPBOARD_ITEM_DATA_SLOT: &str = "__moliClipboardItemData"; @@ -25,12 +23,6 @@ struct ClipboardObjectDeclaration { #[webapi(slot = SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, init = true)] ordered_handlers: (), - - #[webapi(slot = CLIPBOARD_ITEMS_SLOT, init = "undefined")] - items: (), - - #[webapi(slot = CLIPBOARD_TEXT_SLOT, init = "")] - text: (), } #[derive(Default, WebApiFunctionTemplate)] @@ -558,6 +550,9 @@ fn clipboard_read_callback<'s>( return; } }; + let Some(runtime) = clipboard_browser_context(scope, args.this()) else { + return; + }; if let Some(formats) = parsed.formats.unsanitized { let invalid = formats.len() > 1 || formats.iter().any(|format| format != "text/html"); if invalid { @@ -570,7 +565,14 @@ fn clipboard_read_callback<'s>( return; } } - let items = copy_clipboard_items(scope, args.this()); + if reject_denied_clipboard_access(scope, args.this(), "clipboard-read", &mut rv) { + return; + } + let Some(items) = copy_clipboard_items(scope, &runtime) else { + let reason = type_error_value(scope, "Failed to read clipboard data."); + set_rejected_promise(scope, &mut rv, reason); + return; + }; set_resolved_promise(scope, &mut rv, items.into()); } @@ -584,23 +586,17 @@ fn clipboard_read_text_callback<'s>( set_rejected_promise(scope, &mut rv, reason); return; } - if let Some(text) = - get_private_value(scope, args.this(), CLIPBOARD_TEXT_SLOT).filter(|value| value.is_string()) - { - set_resolved_promise(scope, &mut rv, text); + let Some(runtime) = clipboard_browser_context(scope, args.this()) else { + return; + }; + if reject_denied_clipboard_access(scope, args.this(), "clipboard-read", &mut rv) { return; } - let text_promise = first_clipboard_item(scope, args.this()) - .and_then(|item| clipboard_item_data_promise(scope, item, "text/plain")) - .and_then(|promise| { - let converter = v8::Function::builder(clipboard_blob_to_text_callback).build(scope)?; - promise.then(scope, converter) - }); - if let Some(promise) = text_promise { - rv.set(promise.into()); - } else { - let empty = v8::String::empty(scope); - set_resolved_promise(scope, &mut rv, empty.into()); + let bytes = runtime + .clipboard_data_for_type("text/plain") + .unwrap_or_default(); + if let Some(text) = v8_string(scope, &String::from_utf8_lossy(&bytes)) { + set_resolved_promise(scope, &mut rv, text.into()); } } @@ -621,6 +617,9 @@ fn clipboard_write_callback<'s>( return; } }; + if clipboard_browser_context(scope, args.this()).is_none() { + return; + } if parsed.data.len() != 1 { let reason = super::super::new_dom_exception_value( scope, @@ -630,15 +629,22 @@ fn clipboard_write_callback<'s>( set_rejected_promise(scope, &mut rv, reason); return; } + if reject_denied_clipboard_access(scope, args.this(), "clipboard-write", &mut rv) { + return; + } let item = parsed.data[0]; let Some(validation) = clipboard_item_write_validation_promise(scope, item) else { let reason = type_error_value(scope, "Failed to validate ClipboardItem data."); set_rejected_promise(scope, &mut rv, reason); return; }; - let callback_data = v8::Array::new(scope, 2); + let Some(resolver) = v8::PromiseResolver::new(scope) else { + return; + }; + let callback_data = v8::Array::new(scope, 3); let _ = callback_data.set_index(scope, 0, args.this().into()); let _ = callback_data.set_index(scope, 1, item.into()); + let _ = callback_data.set_index(scope, 2, resolver.into()); let Some(store_callback) = v8::Function::builder(clipboard_store_validated_item_callback) .data(callback_data.into()) .build(scope) @@ -647,12 +653,23 @@ fn clipboard_write_callback<'s>( set_rejected_promise(scope, &mut rv, reason); return; }; - let Some(promise) = validation.then(scope, store_callback) else { + let Some(reject_callback) = v8::Function::builder(clipboard_write_rejected_callback) + .data(callback_data.into()) + .build(scope) + else { let reason = type_error_value(scope, "Failed to write ClipboardItem data."); set_rejected_promise(scope, &mut rv, reason); return; }; - rv.set(promise.into()); + if validation + .then2(scope, store_callback, reject_callback) + .is_none() + { + let reason = type_error_value(scope, "Failed to write ClipboardItem data."); + set_rejected_promise(scope, &mut rv, reason); + return; + } + rv.set(resolver.get_promise(scope).into()); } fn clipboard_write_text_callback<'s>( @@ -672,40 +689,14 @@ fn clipboard_write_text_callback<'s>( return; } }; - let Some(text) = v8_string(scope, &parsed.data) else { - rv.set_undefined(); + let Some(runtime) = clipboard_browser_context(scope, args.this()) else { return; }; - let Some(promise) = resolved_promise(scope, text.into()) else { - rv.set_undefined(); + if reject_denied_clipboard_access(scope, args.this(), "clipboard-write", &mut rv) { return; - }; - let Some(item) = build_clipboard_item(scope, vec![("text/plain".to_owned(), promise)]) else { - rv.set_undefined(); - return; - }; - store_clipboard_item(scope, args.this(), item); - set_private_value(scope, args.this(), CLIPBOARD_TEXT_SLOT, text.into()); - set_resolved_promise(scope, &mut rv, v8::undefined(scope).into()); -} - -fn clipboard_blob_to_text_callback<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'_, v8::Value>, -) { - let Ok(blob) = v8::Local::::try_from(args.get(0)) else { - throw_type_error(scope, "Clipboard text data is not a Blob."); - return; - }; - let Some(bytes) = blob::blob_bytes_from_object(scope, blob) else { - throw_type_error(scope, "Clipboard text data is not a Blob."); - return; - }; - let text = String::from_utf8_lossy(&bytes); - if let Some(value) = v8_string(scope, &text) { - rv.set(value.into()); } + runtime.set_clipboard_data(vec![("text/plain".to_owned(), parsed.data.into_bytes())]); + set_resolved_promise(scope, &mut rv, v8::undefined(scope).into()); } fn clipboard_item_write_validation_promise<'s>( @@ -723,7 +714,11 @@ fn clipboard_item_write_validation_promise<'s>( custom_format_count += 1; } let raw = clipboard_item_raw_data_promise(scope, item, &mime_type)?; - entries.push((key, raw)); + let normalized = clipboard_item_data_promise(scope, item, &mime_type)?; + let validation_data = v8::Array::new(scope, 2); + let _ = validation_data.set_index(scope, 0, key); + let _ = validation_data.set_index(scope, 1, normalized.into()); + entries.push((validation_data, raw)); } if custom_format_count > 100 { let reason = super::super::new_dom_exception_value( @@ -748,9 +743,9 @@ fn clipboard_item_write_validation_promise<'s>( ); let _ = state.set_index(scope, 2, v8::Boolean::new(scope, false).into()); - for (key, raw) in entries { + for (validation_data, raw) in entries { let validator = v8::Function::builder(clipboard_validate_write_entry_callback) - .data(key) + .data(validation_data.into()) .build(scope)?; let validated = raw.then(scope, validator)?; let on_fulfilled = v8::Function::builder(clipboard_validation_fulfilled_callback) @@ -767,11 +762,15 @@ fn clipboard_item_write_validation_promise<'s>( fn clipboard_validate_write_entry_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - _rv: v8::ReturnValue<'_, v8::Value>, + mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let mime_type = args - .data() - .to_string(scope) + let Ok(data) = v8::Local::::try_from(args.data()) else { + throw_type_error(scope, "Failed to validate ClipboardItem data."); + return; + }; + let mime_type = data + .get_index(scope, 0) + .and_then(|value| value.to_string(scope)) .map(|value| value.to_rust_string_lossy(scope)) .unwrap_or_default(); if !clipboard_item_type_supported(&mime_type) { @@ -782,6 +781,11 @@ fn clipboard_validate_write_entry_callback<'s>( ); return; } + // Await the representation's normalization as well as its raw type + // validation before committing a byte snapshot to the shared clipboard. + if let Some(normalized) = data.get_index(scope, 1) { + rv.set(normalized); + } let value = args.get(0); let Ok(object) = v8::Local::::try_from(value) else { if mime_type == "image/png" { @@ -875,27 +879,62 @@ fn clipboard_store_validated_item_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok(data) = v8::Local::::try_from(args.data()) else { - throw_type_error(scope, "Failed to write ClipboardItem data."); + let Some((clipboard, item, resolver)) = clipboard_write_callback_state(scope, args.data()) + else { return; }; - let clipboard = data - .get_index(scope, 0) - .and_then(|value| v8::Local::::try_from(value).ok()); - let item = data - .get_index(scope, 1) - .and_then(|value| v8::Local::::try_from(value).ok()); - let (Some(clipboard), Some(item)) = (clipboard, item) else { - throw_type_error(scope, "Failed to write ClipboardItem data."); + // Like Chromium's ScriptPromiseResolver, do not settle a write after its + // realm is retired. Each live write still commits independently. + let Some(runtime) = clipboard_browser_context(scope, clipboard) else { return; }; - store_clipboard_item(scope, clipboard, item); - set_private_value( - scope, - clipboard, - CLIPBOARD_TEXT_SLOT, - v8::undefined(scope).into(), - ); + if clipboard_permission_denied(scope, clipboard, "clipboard-write") { + let reason = super::super::new_dom_exception_value( + scope, + "Clipboard write permission denied.", + "NotAllowedError", + ); + let _ = resolver.reject(scope, reason); + return; + } + let Some(data) = clipboard_item_bytes(scope, item) else { + let reason = type_error_value(scope, "Failed to read validated ClipboardItem data."); + let _ = resolver.reject(scope, reason); + return; + }; + runtime.set_clipboard_data(data); + let _ = resolver.resolve(scope, v8::undefined(scope).into()); +} + +fn clipboard_write_rejected_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some((clipboard, _item, resolver)) = clipboard_write_callback_state(scope, args.data()) + else { + return; + }; + if clipboard_browser_context(scope, clipboard).is_some() { + let _ = resolver.reject(scope, args.get(0)); + } +} + +fn clipboard_write_callback_state<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> Option<( + v8::Local<'s, v8::Object>, + v8::Local<'s, v8::Object>, + v8::Local<'s, v8::PromiseResolver>, +)> { + let data = v8::Local::::try_from(value).ok()?; + let clipboard = v8::Local::::try_from(data.get_index(scope, 0)?).ok()?; + let item = v8::Local::::try_from(data.get_index(scope, 1)?).ok()?; + let resolver = v8::Local::::try_from(data.get_index(scope, 2)?).ok()?; + // This callback data is created only by clipboard_write_callback. + let resolver = unsafe { v8::Local::::cast_unchecked(resolver) }; + Some((clipboard, item, resolver)) } fn throw_dom_exception(scope: &mut v8::PinScope<'_, '_>, message: &str, name: &str) { @@ -903,37 +942,104 @@ fn throw_dom_exception(scope: &mut v8::PinScope<'_, '_>, message: &str, name: &s scope.throw_exception(exception); } -fn store_clipboard_item<'s>( +fn clipboard_permission_denied<'s>( scope: &mut v8::PinScope<'s, '_>, clipboard: v8::Local<'s, v8::Object>, - item: v8::Local<'s, v8::Object>, -) { - let items = v8::Array::new(scope, 1); - let _ = items.set_index(scope, 0, item.into()); - let _ = items.set_integrity_level(scope, v8::IntegrityLevel::Frozen); - set_private_value(scope, clipboard, CLIPBOARD_ITEMS_SLOT, items.into()); + permission: &str, +) -> bool { + let Some(context) = clipboard.get_creation_context(scope) else { + return true; + }; + let Some(host_ptr) = context_host_ptr_from_context_slot(context) else { + return true; + }; + let scope = &mut v8::ContextScope::new(scope, context); + let host = unsafe { &mut *host_ptr }; + let embedding_origin = moli_url::origin_ascii_serialization(host.document_url()); + let Some(requesting_origin) = host + .current_runtime_window_execution_context_identity(scope) + .and_then(|identity| host.storage_context_for_window_execution_context_identity(identity)) + .map(|context| context.storage_key().serialized_storage_key()) + .and_then(|key| moli_storage_key::deserialize_serialized_storage_key(&key)) + .map(|key| key.origin().to_owned()) + else { + return true; + }; + host.permission_state_for_origins(permission, &requesting_origin, &embedding_origin) == "denied" +} + +fn reject_denied_clipboard_access<'s>( + scope: &mut v8::PinScope<'s, '_>, + clipboard: v8::Local<'s, v8::Object>, + permission: &str, + rv: &mut v8::ReturnValue<'_, v8::Value>, +) -> bool { + if !clipboard_permission_denied(scope, clipboard, permission) { + return false; + } + let reason = super::super::new_dom_exception_value( + scope, + "Clipboard permission denied.", + "NotAllowedError", + ); + set_rejected_promise(scope, rv, reason); + true +} + +fn clipboard_browser_context<'s>( + scope: &mut v8::PinScope<'s, '_>, + clipboard: v8::Local<'s, v8::Object>, +) -> Option { + let context = clipboard.get_creation_context(scope)?; + let host_ptr = context_host_ptr_from_context_slot(context)?; + let scope = &mut v8::ContextScope::new(scope, context); + let host = unsafe { &*host_ptr }; + // Blink's CreateForRead/Write entry points return an empty Promise for an + // invalid ScriptState. Check the receiver realm after WebIDL conversion, + // which can itself detach the document through author code. + host.current_runtime_window_execution_context_identity(scope)?; + Some(host.browser_context_runtime()) } fn copy_clipboard_items<'s>( scope: &mut v8::PinScope<'s, '_>, - clipboard: v8::Local<'s, v8::Object>, -) -> v8::Local<'s, v8::Array> { - let item = first_clipboard_item(scope, clipboard); - let result = v8::Array::new(scope, i32::from(item.is_some())); - if let Some(item) = item { - let _ = result.set_index(scope, 0, item.into()); + runtime: &crate::runtime::RendererBrowserContextRuntime, +) -> Option> { + let data = runtime.clipboard_data(); + let result = v8::Array::new(scope, i32::from(!data.is_empty())); + if !data.is_empty() { + let mut entries = Vec::with_capacity(data.len()); + for (mime_type, bytes) in data { + let blob = blob::build_blob_object(scope, bytes, mime_type.clone())?; + let promise = resolved_promise(scope, blob.into())?; + entries.push((mime_type, promise)); + } + let item = build_clipboard_item(scope, entries)?; + result.set_index(scope, 0, item.into())?; } - result + Some(result) } -fn first_clipboard_item<'s>( +fn clipboard_item_bytes<'s>( scope: &mut v8::PinScope<'s, '_>, - clipboard: v8::Local<'s, v8::Object>, -) -> Option> { - get_private_value(scope, clipboard, CLIPBOARD_ITEMS_SLOT) - .and_then(|value| v8::Local::::try_from(value).ok()) - .and_then(|items| items.get_index(scope, 0)) - .and_then(|value| v8::Local::::try_from(value).ok()) + item: v8::Local<'s, v8::Object>, +) -> Option)>> { + let types = get_private_value(scope, item, CLIPBOARD_ITEM_TYPES_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok())?; + let mut data = Vec::with_capacity(types.length() as usize); + for index in 0..types.length() { + let mime_type = types + .get_index(scope, index)? + .to_string(scope)? + .to_rust_string_lossy(scope); + let promise = clipboard_item_data_promise(scope, item, &mime_type)?; + if promise.state() != v8::PromiseState::Fulfilled { + return None; + } + let blob = v8::Local::::try_from(promise.result(scope)).ok()?; + data.push((mime_type, blob::blob_bytes_from_object(scope, blob)?)); + } + Some(data) } fn clipboard_item_data_promise<'s>( diff --git a/moli-renderer-v8/src/runtime/browser_context_runtime.rs b/moli-renderer-v8/src/runtime/browser_context_runtime.rs index 448a50366b..4f475ab638 100644 --- a/moli-renderer-v8/src/runtime/browser_context_runtime.rs +++ b/moli-renderer-v8/src/runtime/browser_context_runtime.rs @@ -171,6 +171,9 @@ pub(crate) struct RendererStoragePartitionIdentity { #[derive(Debug)] struct RendererBrowserContextRuntimeInner { id: super::RendererBrowserContextRuntimeId, + // Keep clipboard representations independent of any page's V8 objects so + // other pages and replacement documents can read them in their own realm. + clipboard_data: Mutex)>>, message_port_registry: crate::message_port_runtime::SharedMessagePortRegistry, broadcast_channel_registry: crate::broadcast_channel_runtime::SharedBroadcastChannelRegistry, browser_resource_runtime: crate::network::BrowserResourceRuntimeBinding, @@ -340,6 +343,23 @@ impl Default for RendererBrowserContextRuntimeOwner { } impl RendererBrowserContextRuntime { + pub(crate) fn clipboard_data(&self) -> Vec<(String, Vec)> { + self.inner.clipboard_data.lock().clone() + } + + pub(crate) fn clipboard_data_for_type(&self, mime_type: &str) -> Option> { + self.inner + .clipboard_data + .lock() + .iter() + .find(|(candidate, _)| candidate == mime_type) + .map(|(_, bytes)| bytes.clone()) + } + + pub(crate) fn set_clipboard_data(&self, data: Vec<(String, Vec)>) { + *self.inner.clipboard_data.lock() = data; + } + // A browser-context runtime is only valid while its thread-affine owner is // retained, so construction returns that owner rather than a bare handle. #[allow(clippy::new_ret_no_self)] @@ -528,6 +548,7 @@ impl RendererBrowserContextRuntime { Self { inner: Arc::new(RendererBrowserContextRuntimeInner { id, + clipboard_data: Mutex::new(Vec::new()), message_port_registry, broadcast_channel_registry, browser_resource_runtime: browser_resource_runtime.clone(), @@ -993,6 +1014,27 @@ mod tests { assert!(runtime.shares_state_with(&clone)); } + #[test] + fn clipboard_data_for_type_returns_an_independent_copy_of_the_matching_bytes() { + let runtime = RendererBrowserContextRuntime::new(); + runtime.set_clipboard_data(vec![ + ("image/png".to_owned(), vec![0; 1024]), + ("text/plain".to_owned(), b"hello".to_vec()), + ]); + + let mut text = runtime.clipboard_data_for_type("text/plain").unwrap(); + assert_eq!(text, b"hello"); + text[0] = b'j'; + assert_eq!( + runtime.clipboard_data_for_type("text/plain").unwrap(), + b"hello" + ); + assert!(runtime.clipboard_data_for_type("text/html").is_none()); + + runtime.set_clipboard_data(Vec::new()); + assert!(runtime.clipboard_data_for_type("text/plain").is_none()); + } + #[test] fn worker_services_stay_deferred_until_first_use() { let runtime = RendererBrowserContextRuntime::new(); diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/clipboard_storage.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/clipboard_storage.rs new file mode 100644 index 0000000000..31cd327746 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/clipboard_storage.rs @@ -0,0 +1,551 @@ +use super::*; + +const CLIPBOARD_SNAPSHOT_PROBE: &str = r#" +(async () => { + const clipboard = navigator.clipboard; + const source = new Blob(['hello δΈ–η•Œ 🦊'], {type: 'text/plain'}); + const item = new ClipboardItem({'text/plain': source}); + await clipboard.write([item]); + const first = (await clipboard.read())[0]; + const second = (await clipboard.read())[0]; + const blob = await first.getType('text/plain'); + if (first === item || first === second || blob === source) return 'reused author objects'; + if (!(first instanceof ClipboardItem) || !(blob instanceof Blob)) return 'wrong realm'; + if (first.types.join(',') !== 'text/plain' || await blob.text() !== 'hello δΈ–η•Œ 🦊') return 'bytes changed'; + Object.defineProperty(item, 'getType', {get() {throw Error('author getType');}}); + Object.defineProperty(source, 'text', {get() {throw Error('author text');}}); + if (await clipboard.readText() !== 'hello δΈ–η•Œ 🦊') return 'read author properties'; + await clipboard.writeText('replacement'); + const replacement = (await clipboard.read())[0]; + if (replacement === first || await (await replacement.getType('text/plain')).text() !== 'replacement') { + return 'replacement failed'; + } + return 'ok'; +})() +"#; + +fn assert_clipboard_probe(vm: &mut ScriptVm, script: &str) { + vm.eval(&format!( + "globalThis.__clipboardStorageResult = 'pending'; ({script}).then(\ + result => globalThis.__clipboardStorageResult = result, \ + error => globalThis.__clipboardStorageResult = String(error));" + )) + .expect("clipboard probe should evaluate"); + vm.eval("0").expect("clipboard promises should drain"); + assert_eq!( + vm.eval("globalThis.__clipboardStorageResult") + .expect("clipboard result should evaluate"), + "ok" + ); +} + +#[test] +fn clipboard_storage_reads_new_items_and_blobs_from_byte_snapshots() { + let mut vm = new_storage_test_vm("https://clipboard-storage.test/"); + assert_clipboard_probe(&mut vm, CLIPBOARD_SNAPSHOT_PROBE); +} + +#[test] +fn clipboard_storage_detached_handles_cannot_access_shared_data() { + let mut vm = new_parsed_test_vm( + "https://clipboard-storage.test/", + "", + ); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const stale = frame.contentWindow.navigator.clipboard; + const item = new frame.contentWindow.ClipboardItem({'text/plain': 'stale item'}); + await navigator.clipboard.writeText('good'); + frame.remove(); + const operations = { + read: () => stale.read(), + readText: () => stale.readText(), + write: () => stale.write([item]), + writeText: () => stale.writeText('stale text') + }; + for (const [name, operation] of Object.entries(operations)) { + if (operation() !== undefined) return name + ' returned a Promise for a detached realm'; + } + if (await navigator.clipboard.readText() !== 'good') return 'detached handle changed shared data'; + return 'ok'; +})() +"#, + ); +} + +#[test] +fn clipboard_storage_detached_pending_writes_do_not_commit_or_settle() { + let mut vm = new_parsed_test_vm( + "https://clipboard-storage.test/", + "", + ); + vm.eval( + r#" + navigator.clipboard.writeText('good'); + globalThis.clipboardWriteOutcomes = []; + for (const reject of [false, true]) { + const frame = document.createElement('iframe'); + document.body.append(frame); + let settle; + const data = new Promise((resolve, fail) => settle = reject ? fail : resolve); + const item = new frame.contentWindow.ClipboardItem({'text/plain': data}); + const index = clipboardWriteOutcomes.push('pending') - 1; + frame.contentWindow.navigator.clipboard.write([item]).then( + () => clipboardWriteOutcomes[index] = 'resolved', + error => clipboardWriteOutcomes[index] = error.name + ); + frame.remove(); + settle(reject ? Error('retired representation') : 'stale text'); + } + "#, + ) + .expect("detached writes should not throw synchronously"); + // eval drains microtasks, including representation validation and commit. + assert_eq!( + vm.eval("clipboardWriteOutcomes.join(',')") + .expect("detached write outcomes"), + "pending,pending" + ); + assert_clipboard_probe( + &mut vm, + r#" +(async () => (await navigator.clipboard.readText()) === 'good' ? 'ok' : 'detached write committed')() +"#, + ); +} + +#[test] +fn clipboard_storage_rechecks_realm_after_argument_conversion() { + let mut vm = new_parsed_test_vm( + "https://clipboard-storage.test/", + "", + ); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + await navigator.clipboard.writeText('good'); + for (const method of ['writeText', 'write', 'read']) { + const frame = document.createElement('iframe'); + document.body.append(frame); + const clipboard = frame.contentWindow.navigator.clipboard; + const item = new frame.contentWindow.ClipboardItem({'text/plain': 'stale item'}); + const argumentsByMethod = { + writeText: {toString() {frame.remove(); return 'stale text';}}, + write: {[Symbol.iterator]: function* () {yield item; frame.remove();}}, + read: {get unsanitized() {frame.remove(); return ['invalid-format'];}} + }; + if (clipboard[method](argumentsByMethod[method]) !== undefined) return method + ' used a retired realm'; + } + if (await navigator.clipboard.readText() !== 'good') return 'argument conversion allowed a stale commit'; + return 'ok'; +})() +"#, + ); +} + +#[test] +fn clipboard_storage_read_text_selects_plain_text_representation() { + let mut vm = new_storage_test_vm("https://clipboard-storage.test/"); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + const clipboard = navigator.clipboard; + await clipboard.write([new ClipboardItem({'text/html': 'text', 'text/plain': 'text'})]); + if (await clipboard.readText() !== 'text') return 'wrong representation'; + await clipboard.write([new ClipboardItem({'text/html': 'html only'})]); + if (await clipboard.readText() !== '') return 'missing plain text was not empty'; + return 'ok'; +})() +"#, + ); +} + +#[test] +fn clipboard_storage_waits_for_all_representations_and_preserves_data_after_rejection() { + let mut vm = new_storage_test_vm("https://clipboard-storage.test/"); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + const clipboard = navigator.clipboard; + await clipboard.writeText('before'); + let resolve; + const pending = new Promise(done => resolve = done); + const write = clipboard.write([new ClipboardItem({ + 'text/plain': Promise.resolve('after'), 'text/html': pending + })]); + if (await clipboard.readText() !== 'before') return 'partially committed write'; + resolve(new Blob(['after'], {type: 'text/html'})); + await write; + const item = (await clipboard.read())[0]; + if (item.types.join(',') !== 'text/plain,text/html') return 'representation order'; + if (await clipboard.readText() !== 'after' || + await (await item.getType('text/html')).text() !== 'after') return 'representation bytes'; + try { + await clipboard.write([new ClipboardItem({ + 'text/plain': 'bad replacement', + 'text/html': new Blob(['wrong type'], {type: 'text/plain'}) + })]); + return 'accepted mismatched representation'; + } catch (error) { + if (error.name !== 'NotAllowedError') return 'wrong rejection'; + } + if (await clipboard.readText() !== 'after') return 'failed write replaced clipboard'; + const sentinel = Error('rejected representation'); + let reject; + const rejection = new Promise((_, fail) => reject = fail); + const failedWrite = clipboard.write([new ClipboardItem({'text/plain': rejection})]); + reject(sentinel); + try {await failedWrite; return 'accepted rejected representation';} + catch (error) {if (error !== sentinel) return 'replaced rejection reason';} + if (await clipboard.readText() !== 'after') return 'rejection cleared clipboard'; + return 'ok'; +})() +"#, + ); +} + +#[test] +fn clipboard_storage_overlapping_writes_commit_independently() { + // Mirror Chromium's independent ClipboardPromise commits, including older + // calls that finish last. + let mut vm = new_storage_test_vm("https://clipboard-storage.test/"); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + const clipboard = navigator.clipboard; + for (const method of ['writeText', 'write']) { + let resolve; + let result = 'pending'; + const older = clipboard.write([new ClipboardItem({ + 'text/plain': new Promise(done => resolve = done) + })]).then(() => result = 'resolved', error => result = error.name); + if (method === 'writeText') await clipboard.writeText('newer'); + else await clipboard.write([new ClipboardItem({'text/plain': 'newer'})]); + if (result !== 'pending') return method + ' settled an independent write'; + if (await clipboard.readText() !== 'newer') return method + ' did not commit'; + + resolve('older'); + await older; + if (result !== 'resolved') return 'older write rejected after ' + method; + if (await clipboard.readText() !== 'older') return 'last completed write did not commit'; + } + return 'ok'; +})() +"#, + ); +} + +#[test] +fn clipboard_storage_failed_newer_write_does_not_cancel_pending_write() { + let mut vm = new_storage_test_vm("https://clipboard-storage.test/"); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + const clipboard = navigator.clipboard; + await clipboard.writeText('initial'); + let resolve; + let result = 'pending'; + const older = clipboard.write([new ClipboardItem({ + 'text/plain': new Promise(done => resolve = done) + })]).then(() => result = 'resolved', error => result = error.name); + const sentinel = Error('newer representation rejected'); + try { + await clipboard.write([new ClipboardItem({'text/plain': Promise.reject(sentinel)})]); + return 'accepted rejected representation'; + } catch (error) { + if (error !== sentinel) return 'replaced rejection reason'; + } + if (result !== 'pending') return 'failed newer write settled an independent write'; + if (await clipboard.readText() !== 'initial') return 'failed write changed clipboard'; + + resolve('older'); + await older; + if (result !== 'resolved') return 'older write rejected after failed newer write'; + if (await clipboard.readText() !== 'older') return 'older write did not commit'; + return 'ok'; +})() +"#, + ); +} + +#[test] +fn clipboard_storage_overlapping_child_realm_writes_commit_independently() { + let mut vm = new_parsed_test_vm( + "https://clipboard-storage.test/", + "", + ); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const child = frame.contentWindow; + + for (const [olderRealm, newerRealm] of [[window, child], [child, window]]) { + let resolve; + let result = 'pending'; + const older = olderRealm.navigator.clipboard.write([new olderRealm.ClipboardItem({ + 'text/plain': new olderRealm.Promise(done => resolve = done) + })]).then(() => result = 'resolved', error => result = error.name); + await newerRealm.navigator.clipboard.writeText('newer realm'); + if (result !== 'pending') return 'cross-realm write settled an independent write'; + if (await olderRealm.navigator.clipboard.readText() !== 'newer realm') return 'newer realm did not commit'; + + resolve('older realm'); + await older; + if (result !== 'resolved') return 'older realm write rejected'; + if (await newerRealm.navigator.clipboard.readText() !== 'older realm') return 'older realm did not commit'; + } + return 'ok'; +})() +"#, + ); +} + +const CLIPBOARD_FRAME_PROBE: &str = r#" +(async () => { + const frame = document.createElement('iframe'); + const loaded = new Promise(resolve => frame.onload = resolve); + document.body.append(frame); + await loaded; + const child = frame.contentWindow; + frame.focus(); + await navigator.clipboard.writeText('parent β†’ child'); + if (await child.navigator.clipboard.readText() !== 'parent β†’ child') return 'parent write invisible'; + const childItem = (await child.navigator.clipboard.read())[0]; + const childBlob = await childItem.getType('text/plain'); + if (!(childItem instanceof child.ClipboardItem) || !(childBlob instanceof child.Blob)) return 'child realm'; + await child.navigator.clipboard.write([new child.ClipboardItem({ + 'text/plain': new child.Blob(['child β†’ parent'], {type: 'text/plain'}) + })]); + if (await navigator.clipboard.readText() !== 'child β†’ parent') return 'child write invisible'; + const parentItem = (await navigator.clipboard.read())[0]; + if (!(parentItem instanceof ClipboardItem) || !((await parentItem.getType('text/plain')) instanceof Blob)) { + return 'parent realm'; + } + document.body.tabIndex = 0; + document.body.focus(); + frame.remove(); + if (await navigator.clipboard.readText() !== 'child β†’ parent') return 'frame removal cleared clipboard'; + return 'ok'; +})() +"#; + +#[test] +fn clipboard_storage_is_shared_with_child_realms() { + let mut vm = new_parsed_test_vm( + "https://clipboard-storage.test/", + "", + ); + // The synchronous VM fixture does not run the frame's later load task. + let script = CLIPBOARD_FRAME_PROBE.replace(" await loaded;", " void loaded;"); + assert_clipboard_probe(&mut vm, &script); +} + +fn clipboard_permission( + name: &str, + setting: &str, +) -> crate::protocol_types::PermissionOverrideRegistration { + crate::protocol_types::PermissionOverrideRegistration { + permission: serde_json::json!({"name": name}), + setting: setting.to_owned(), + origin: Some("https://clipboard-storage.test".to_owned()), + embedded_origin: None, + } +} + +#[test] +fn clipboard_storage_respects_independent_read_and_write_permission_denials() { + let mut vm = new_storage_test_vm("https://clipboard-storage.test/"); + vm.set_permission_overrides(&[clipboard_permission("clipboard-read", "denied")]); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + await navigator.clipboard.writeText('write still allowed'); + for (const operation of ['read', 'readText']) { + try {await navigator.clipboard[operation](); return 'allowed denied ' + operation;} + catch (error) {if (error.name !== 'NotAllowedError') return 'read error';} + } + return 'ok'; +})() +"#, + ); + vm.set_permission_overrides(&[clipboard_permission("clipboard-write", "denied")]); + assert_clipboard_probe( + &mut vm, + r#" +(async () => { + const writes = [ + () => navigator.clipboard.writeText('denied replacement'), + () => navigator.clipboard.write([new ClipboardItem({'text/plain': 'denied replacement'})]) + ]; + for (const write of writes) { + try {await write(); return 'allowed denied write';} + catch (error) {if (error.name !== 'NotAllowedError') return 'write error';} + } + if (await navigator.clipboard.readText() !== 'write still allowed') return 'denied write changed data'; + const item = (await navigator.clipboard.read())[0]; + if (await (await item.getType('text/plain')).text() !== 'write still allowed') return 'read denied by write permission'; + return 'ok'; +})() +"#, + ); +} + +#[test] +fn clipboard_storage_rechecks_write_permission_after_pending_data_settles() { + let mut vm = new_storage_test_vm("https://clipboard-storage.test/"); + vm.eval( + r#" + navigator.clipboard.writeText('before revocation'); + const pending = new Promise(resolve => globalThis.__clipboardResolve = resolve); + navigator.clipboard.write([new ClipboardItem({'text/plain': pending})]).then( + () => globalThis.__clipboardWriteResult = 'accepted', + error => globalThis.__clipboardWriteResult = error.name + ); + "#, + ) + .expect("pending clipboard write should start"); + vm.set_permission_overrides(&[clipboard_permission("clipboard-write", "denied")]); + vm.eval("__clipboardResolve('after revocation')") + .expect("clipboard data should settle"); + assert_eq!( + vm.eval("__clipboardWriteResult").expect("write result"), + "NotAllowedError" + ); + assert_clipboard_probe( + &mut vm, + r#" +(async () => (await navigator.clipboard.readText()) === 'before revocation' ? 'ok' : 'revoked write committed')() +"#, + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn clipboard_storage_overlapping_page_writes_commit_independently() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()) + .expect("clipboard test loader"); + let owner = crate::runtime::RendererBrowserContextRuntime::new(); + let new_page = || { + crate::runtime::PageVmTaskExecutorTestHarness::new_with_browser_context_runtime( + url::Url::parse("https://clipboard-storage.test/").expect("clipboard URL"), + &loader, + owner.handle(), + ) + }; + let mut older_page = new_page(); + older_page + .eval( + r#" + navigator.clipboard.writeText('initial'); + const pending = new Promise(resolve => globalThis.__clipboardResolve = resolve); + globalThis.__clipboardWriteResult = 'pending'; + navigator.clipboard.write([new ClipboardItem({'text/plain': pending})]).then( + () => globalThis.__clipboardWriteResult = 'resolved', + error => globalThis.__clipboardWriteResult = error.name + ); + "#, + ) + .expect("older page clipboard write should start"); + + let mut newer_page = new_page(); + assert_clipboard_probe( + &mut newer_page, + r#" +(async () => { + await navigator.clipboard.writeText('newer page'); + if (await navigator.clipboard.readText() !== 'newer page') return 'newer page did not commit'; + return 'ok'; +})() +"#, + ); + + assert_eq!( + older_page + .eval("globalThis.__clipboardWriteResult") + .expect("older write should remain pending"), + "pending" + ); + older_page + .eval("__clipboardResolve('older page')") + .expect("older page clipboard data should settle"); + assert_eq!( + older_page + .eval("globalThis.__clipboardWriteResult") + .expect("older write result"), + "resolved" + ); + assert_clipboard_probe( + &mut newer_page, + r#" +(async () => (await navigator.clipboard.readText()) === 'older page' ? 'ok' : 'older page did not commit')() +"#, + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn clipboard_storage_survives_page_teardown_and_keeps_browser_contexts_separate() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()) + .expect("clipboard test loader"); + let owner = crate::runtime::RendererBrowserContextRuntime::new(); + let new_page = || { + crate::runtime::PageVmTaskExecutorTestHarness::new_with_browser_context_runtime( + url::Url::parse("https://clipboard-storage.test/").expect("clipboard URL"), + &loader, + owner.handle(), + ) + }; + { + let mut page = new_page(); + assert_clipboard_probe( + &mut page, + r#" +(async () => { + await navigator.clipboard.write([new ClipboardItem({ + 'text/plain': new Blob(['survives teardown'], {type: 'text/plain'}), + 'image/png': new Blob([Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAIAAABLixI0AAAAJklEQVR4nGJg+M9ANTRq1qhZo2aNmjVq1qhZo2ZhIAAAAAD//wMA1XBuro0zsokAAAAASUVORK5CYII='), ch => ch.charCodeAt(0))], {type: 'image/png'}) + })]); + return 'ok'; +})() +"#, + ); + } + let mut replacement = new_page(); + assert_clipboard_probe( + &mut replacement, + r#" +(async () => { + if (await navigator.clipboard.readText() !== 'survives teardown') return 'lost page data'; + const item = (await navigator.clipboard.read())[0]; + const blob = await item.getType('image/png'); + if (!(item instanceof ClipboardItem) || !(blob instanceof Blob)) return 'retired realm'; + const expected = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAIAAABLixI0AAAAJklEQVR4nGJg+M9ANTRq1qhZo2aNmjVq1qhZo2ZhIAAAAAD//wMA1XBuro0zsokAAAAASUVORK5CYII='), ch => ch.charCodeAt(0)); + if (Array.from(new Uint8Array(await blob.arrayBuffer())).join(',') !== expected.join(',')) return 'binary data'; + return 'ok'; +})() +"#, + ); + let mut separate = new_storage_test_vm("https://clipboard-storage.test/"); + assert_clipboard_probe( + &mut separate, + r#" +(async () => { + if (await navigator.clipboard.readText() !== '' || (await navigator.clipboard.read()).length !== 0) { + return 'another browser context clipboard'; + } + return 'ok'; +})() +"#, + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs index de8e9068b8..963963b24c 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs @@ -8703,7 +8703,7 @@ fn async_clipboard_interfaces_are_branded_and_round_trip_text_data() { assert_eq!( vm.eval("JSON.stringify(globalThis.__asyncClipboardProbe)") .expect("Async Clipboard promise result should evaluate"), - r#"{"state":"done","initialBlob":[true,"text/plain","hello"],"writtenText":"hello","read":[1,true,"opaque",false],"textWrite":[1,true,"text/plain","next"],"rejections":["TypeError","TypeError","TypeError","NotAllowedError","NotAllowedError","TypeError","NotFoundError","TypeError","NotAllowedError","NotAllowedError","TypeError"]}"# + r#"{"state":"done","initialBlob":[true,"text/plain","hello"],"writtenText":"hello","read":[1,false,"opaque",false],"textWrite":[1,true,"text/plain","next"],"rejections":["TypeError","TypeError","TypeError","NotAllowedError","NotAllowedError","TypeError","NotFoundError","TypeError","NotAllowedError","NotAllowedError","TypeError"]}"# ); } diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 7df1f0fa24..ae3505c82b 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -2,6 +2,7 @@ use super::*; mod broadcast_channel; mod chrome; +mod clipboard_storage; mod console; mod crypto_misc; mod crypto_subtle_aes;