diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs index 2c9e825c0a..538dcc5d48 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result, anyhow}; use crate::context_bootstrap::{ crypto::finalize_crypto_realm_bindings, events::finalize_pointer_event_realm_bindings, + navigator_runtime::finalize_navigator_credentials_realm_bindings, notification_runtime::finalize_notification_realm_bindings, performance_runtime::finalize_performance_observer_realm_bindings, web_audio_runtime::finalize_base_audio_context_realm_bindings, @@ -13,6 +14,7 @@ enum RealmDependentFinalizer { DocumentRealmBindings, NodeMixinUnscopables, GlobalEventHandlersSecureContextSurface, + NavigatorCredentialsSecureContextSurface, CryptoSecureContextSurface, BaseAudioContextSecureContextSurface, XmlHttpRequestEventTargetState, @@ -40,6 +42,10 @@ const REALM_DEPENDENT_FINALIZER_ALLOWLIST: &[(&str, RealmDependentFinalizer)] = ), // These entries install private realm state, context-conditional surface, // or constructor data whose JavaScript identity must be realm-local. + ( + "Navigator", + RealmDependentFinalizer::NavigatorCredentialsSecureContextSurface, + ), ( "BaseAudioContext", RealmDependentFinalizer::BaseAudioContextSecureContextSurface, @@ -113,6 +119,9 @@ pub(super) fn finalize_materialized_interface<'s>( RealmDependentFinalizer::BaseAudioContextSecureContextSurface => { finalize_base_audio_context_realm_bindings(scope, prototype) } + RealmDependentFinalizer::NavigatorCredentialsSecureContextSurface => { + finalize_navigator_credentials_realm_bindings(scope, prototype) + } RealmDependentFinalizer::GlobalEventHandlersSecureContextSurface => { crate::context_bootstrap::window_events::finalize_secure_global_event_handler_realm_bindings( scope, prototype, @@ -171,6 +180,7 @@ mod tests { "Element", "DocumentType", "CharacterData", + "Navigator", "BaseAudioContext", "Crypto", "XMLHttpRequestEventTarget", diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs index 1c2a9a602b..85ed62a9fd 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs @@ -21,7 +21,7 @@ pub(in crate::context_bootstrap) use self::navigator::{ SERVICE_WORKER_OWNER_TOKEN_SLOT, STORAGE_BUCKET_MANAGER_CHILD_HANDLE_SLOT, STORAGE_BUCKET_MANAGER_POPUP_ID_SLOT, STORAGE_MANAGER_CHILD_HANDLE_SLOT, STORAGE_MANAGER_POPUP_ID_SLOT, build_storage_manager_worker_template, - current_protocol_user_gesture_activation, + current_protocol_user_gesture_activation, finalize_navigator_credentials_realm_bindings, install_storage_manager_constructor_template_bindings, navigator_identity_profile, navigator_receiver_branded, service_worker_owner_token_value, set_navigator_identity_profile, }; diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials.rs index 8ee84ab2d0..6fb35035ce 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials.rs @@ -7,9 +7,11 @@ use moli_webapi_declare::WebApiFunctionTemplate; mod base64url; +mod container; mod json_options; mod schema; mod value; +pub(super) use container::build as build_container; use crate::{native_bridge::throw_dom_exception, util::v8str, web_api_interfaces}; @@ -90,6 +92,7 @@ pub(super) fn install<'s>( // Static members are installed on the constructor template by the derive. let prototype = template.prototype_template(scope); match interface_name { + "CredentialsContainer" => container::install(scope, prototype), "Credential" => { CredentialDeclaration::initialize_template(scope, template); CredentialDeclaration::initialize_prototype_template(scope, prototype); diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials/container.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials/container.rs new file mode 100644 index 0000000000..e61e3125a1 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials/container.rs @@ -0,0 +1,179 @@ +//! Native credential-manager entry points without a credential-store backend. +//! +//! WebIDL conversion and fully-active/pre-aborted checks still apply when no +//! credential can be produced. The generated Promise adapter handles receiver +//! and conversion errors in the function realm. Do not manufacture credentials +//! or claim to persist them. + +mod options; + +use anyhow::Result; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +use crate::{ + native_bridge::{OwnerDispatchScope, throw_dom_exception}, + util::context_host_ptr_from_context_slot, + web_api_interfaces, webidl, +}; + +use options::{CreateArgs, GetArgs, StoreArgs}; + +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::CredentialsContainer)] +struct ContainerObject<'s> { + #[webapi(prototype)] + prototype: v8::Local<'s, v8::Object>, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::CredentialsContainer, enumerable, receiver)] +struct ContainerPrototype { + #[webapi(method, returns_promise, length = 0, callback = get)] + get: (), + #[webapi(method, returns_promise, length = 1, callback = store)] + store: (), + #[webapi(method, returns_promise, length = 0, callback = create)] + create: (), + #[webapi(method, returns_promise, length = 0, callback = prevent_silent_access)] + prevent_silent_access: (), +} + +pub(super) fn install<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + prototype: v8::Local<'s, v8::ObjectTemplate>, +) { + ContainerPrototype::initialize_prototype_template(scope, prototype); +} + +pub(in crate::context_bootstrap::navigator_runtime) fn build<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result> { + let prototype = crate::context_bootstrap::ensure_intrinsic_interface_prototype( + scope, + "CredentialsContainer", + )?; + ContainerObject::new(prototype) + .bind(scope) + .map_err(Into::into) +} + +fn check_fully_active(scope: &mut v8::PinScope<'_, '_>) -> bool { + // Current settings come from the function realm, never from an author + // property such as window.document or a retained WindowProxy association. + let context = scope.get_current_context(); + let fully_active = context_host_ptr_from_context_slot(context).is_some_and(|host_ptr| { + let host = unsafe { &*host_ptr }; + let Some(identity) = host.window_execution_context_identity_for_access_check(context) + else { + return false; + }; + if !host.window_execution_context_identity_is_current(identity) { + return false; + } + let document = match identity.dispatch_scope() { + OwnerDispatchScope::Top => Some(host.document_handle()), + OwnerDispatchScope::Child(frame) => host.child_browsing_context_document_handle(frame), + OwnerDispatchScope::LightweightPopup(id) => host.lightweight_popup_document_handle(id), + }; + document.is_some_and(|document| host.document_is_fully_active(document)) + }); + if !fully_active { + throw_dom_exception( + scope, + "InvalidStateError", + 11, + "The document is not fully active.", + ); + } + fully_active +} + +fn check_aborted<'s>( + scope: &mut v8::PinScope<'s, '_>, + signal: Option>, +) -> bool { + if let Some(signal) = signal + && signal.0.is_aborted(scope) + { + let reason = signal + .0 + .reason(scope) + .unwrap_or_else(|| v8::undefined(scope).into()); + scope.throw_exception(reason); + return true; + } + false +} + +fn backend_unavailable(scope: &mut v8::PinScope<'_, '_>) { + throw_dom_exception( + scope, + "NotSupportedError", + 9, + "No supported credential backend is available.", + ); +} + +fn get<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + if !check_fully_active(scope) || check_aborted(scope, parsed.options.0.signal) { + return; + } + backend_unavailable(scope); +} + +fn create<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + if !check_fully_active(scope) { + return; + } + let options = parsed.options.0; + // Unlike get(), create() checks multiple credential types before abort. + let types = usize::from(options.password.is_some()) + + usize::from(options.federated.is_some()) + + usize::from(options.public_key.is_some()); + if types > 1 { + backend_unavailable(scope); + return; + } + if !check_aborted(scope, options.signal) { + backend_unavailable(scope); + } +} + +fn store<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s>, +) { + if webidl::parse_args::(scope, &args).is_some() && check_fully_active(scope) { + backend_unavailable(scope); + } +} + +fn prevent_silent_access<'s>( + scope: &mut v8::PinScope<'s, '_>, + _args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s>, +) { + if !check_fully_active(scope) { + return; + } + // The default prevent-silent-access flag is true. With no credential store + // or automatic sign-in path, this cannot enable silent access or persist a + // credential; its idempotent operation can complete normally. + let value = v8::undefined(scope).into(); + crate::context_bootstrap::stream_adapter::set_resolved_promise(scope, &mut rv, value); +} diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials/container/options.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials/container/options.rs new file mode 100644 index 0000000000..f92cd43de0 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/credentials/container/options.rs @@ -0,0 +1,308 @@ +//! Convert credential dictionaries before entering the backend algorithm. +//! +//! All dictionaries preserve WebIDL's inherited-then-lexical member order. +//! Underscored fields are converted even though the unavailable backend cannot +//! consume them. BufferSource validation uses native identity and never copies +//! authenticator input or reads an author-visible `.buffer` property. + +use crate::{abort_signal_route::ResolvedAbortSignal, web_api_interfaces, webidl}; + +use super::super::{ + schema::{Entity, Parameters, RelyingParty, Selection}, + value::{Dictionary, Text}, +}; + +#[derive(Clone, Copy, webidl::WebIdlEnum)] +#[webidl(name = "CredentialMediationRequirement")] +enum Mediation { + Silent, + Optional, + Conditional, + Required, +} + +pub(super) struct Signal<'s>(pub(super) ResolvedAbortSignal<'s>); + +impl<'s> webidl::WebIdlConverter<'s> for Signal<'s> { + type Options = (); + + fn convert( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, + context: webidl::Context, + _options: &Self::Options, + ) -> Result { + let signal = v8::Local::::try_from(value) + .ok() + .filter(|object| web_api_interfaces::AbortSignal::is_instance(scope, *object)) + .and_then(|object| moli_webapi_declare::web_api_object_target(scope, object)) + .and_then(|object| ResolvedAbortSignal::resolve(scope, object)); + signal + .map(Self) + .ok_or_else(|| webidl::WebIdlError::cannot_convert(context, "AbortSignal")) + } +} + +// This is validation for WebAuthn's BufferSource without AllowShared or +// AllowResizable. The existing byte-copy converter also accepts shared input, +// so it cannot be used for these dictionaries. +struct Buffer; + +impl<'s> webidl::WebIdlConverter<'s> for Buffer { + type Options = (); + + fn convert( + _scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, + context: webidl::Context, + _options: &Self::Options, + ) -> Result { + let binary = value.is_array_buffer() || value.is_array_buffer_view(); + if binary && !crate::blob::buffer_source_has_shared_or_resizable_backing_store(value) { + Ok(Self) + } else { + Err(webidl::WebIdlError::cannot_convert(context, "BufferSource")) + } + } +} + +pub(super) struct PasswordInit; + +impl<'s> webidl::WebIdlConverter<'s> for PasswordInit { + type Options = (); + + fn convert( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, + context: webidl::Context, + _options: &Self::Options, + ) -> Result { + if let Ok(object) = v8::Local::::try_from(value) + && web_api_interfaces::HTMLFormElement::is_instance(scope, object) + { + return Ok(Self); + } + webidl::convert::>(scope, value, context)?; + Ok(Self) + } +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "CredentialsContainer.get")] +pub(super) struct GetArgs<'s> { + #[webidl(converter = "raw", default = Dictionary::empty(scope)?)] + pub(super) options: Dictionary>, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "CredentialsContainer.create")] +pub(super) struct CreateArgs<'s> { + #[webidl(converter = "raw", default = Dictionary::empty(scope)?)] + pub(super) options: Dictionary>, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "CredentialsContainer.store")] +pub(super) struct StoreArgs<'s> { + #[webidl(required, interface = web_api_interfaces::Credential)] + _credential: v8::Local<'s, v8::Object>, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "CredentialRequestOptions")] +pub(super) struct Request<'s> { + #[webidl(converter = "raw")] + _federated: Option>, + #[webidl(converter = "enum", default = Mediation::Optional)] + _mediation: Mediation, + #[webidl(default = false)] + _password: bool, + #[webidl(converter = "raw")] + _public_key: Option>, + #[webidl(converter = "raw")] + pub(super) signal: Option>, + #[webidl(converter = "raw")] + _ui_mode: Option, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "CredentialCreationOptions")] +pub(super) struct Creation<'s> { + #[webidl(converter = "raw")] + pub(super) federated: Option>, + #[webidl(converter = "enum", default = Mediation::Optional)] + _mediation: Mediation, + #[webidl(converter = "raw")] + pub(super) password: Option, + #[webidl(converter = "raw")] + pub(super) public_key: Option>, + #[webidl(converter = "raw")] + pub(super) signal: Option>, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "FederatedCredentialRequestOptions")] +struct FederatedRequest { + #[webidl(converter = "raw")] + _protocols: Option>, + #[webidl(converter = "raw")] + _providers: Option>, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "CredentialData")] +struct CredentialData { + #[webidl(required, converter = "usv_string")] + _id: String, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "FederatedCredentialInit")] +pub(super) struct FederatedInit { + #[webidl(inherit)] + _base: CredentialData, + #[webidl(name = "iconURL", converter = "usv_string")] + _icon_url: Option, + #[webidl(converter = "usv_string")] + _name: Option, + #[webidl(required, converter = "usv_string")] + _origin: String, + #[webidl(converter = "raw")] + _protocol: Option, + #[webidl(required, converter = "usv_string")] + _provider: String, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "PasswordCredentialData")] +struct PasswordData { + #[webidl(inherit)] + _base: CredentialData, + #[webidl(name = "iconURL", converter = "usv_string")] + _icon_url: Option, + #[webidl(converter = "usv_string")] + _name: Option, + #[webidl(required, converter = "usv_string")] + _origin: String, + #[webidl(required, converter = "usv_string")] + _password: String, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "PublicKeyCredentialCreationOptions")] +pub(super) struct PublicKeyCreation { + #[webidl(converter = "raw", default = Text::from("none"))] + _attestation: Text, + #[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))] + _attestation_formats: webidl::Sequence, + #[webidl(converter = "raw")] + _authenticator_selection: Option>, + #[webidl(required, converter = "raw")] + _challenge: Buffer, + #[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))] + _exclude_credentials: webidl::Sequence>, + #[webidl(converter = "raw")] + _extensions: Option>, + #[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))] + _hints: webidl::Sequence, + #[webidl(required, converter = "raw")] + _pub_key_cred_params: webidl::Sequence>, + #[webidl(required, converter = "raw")] + _rp: Dictionary, + _timeout: Option, + #[webidl(required, converter = "raw")] + _user: Dictionary, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "PublicKeyCredentialRequestOptions")] +struct PublicKeyRequest { + #[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))] + _allow_credentials: webidl::Sequence>, + #[webidl(required, converter = "raw")] + _challenge: Buffer, + #[webidl(converter = "raw")] + _extensions: Option>, + #[webidl(converter = "raw", default = webidl::Sequence(Vec::new()))] + _hints: webidl::Sequence, + #[webidl(converter = "raw")] + _rp_id: Option, + _timeout: Option, + #[webidl(converter = "raw", default = Text::from("preferred"))] + _user_verification: Text, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "PublicKeyCredentialUserEntity")] +struct User { + #[webidl(inherit)] + _base: Entity, + #[webidl(required, converter = "raw")] + _display_name: Text, + #[webidl(required, converter = "raw")] + _id: Buffer, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "PublicKeyCredentialDescriptor")] +struct Descriptor { + #[webidl(required, converter = "raw")] + _id: Buffer, + #[webidl(converter = "raw")] + _transports: Option>, + #[webidl(required, name = "type", converter = "raw")] + _credential_type: Text, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "AuthenticationExtensionsClientInputs")] +struct Extensions { + #[webidl(converter = "raw")] + _appid: Option, + #[webidl(converter = "raw")] + _appid_exclude: Option, + #[webidl(converter = "raw")] + _cred_blob: Option, + _cred_props: Option, + #[webidl(converter = "usv_string")] + _credential_protection_policy: Option, + #[webidl(default = false)] + _enforce_credential_protection_policy: bool, + _get_cred_blob: Option, + _hmac_create_secret: Option, + #[webidl(converter = "raw")] + _large_blob: Option>, + _min_pin_length: Option, + #[webidl(converter = "raw")] + _prf: Option>, + #[webidl(name = "remoteClientDataJSON", converter = "raw")] + _remote_client_data_json: Option, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "AuthenticationExtensionsLargeBlobInputs")] +struct LargeBlob { + _read: Option, + #[webidl(converter = "raw")] + _support: Option, + #[webidl(converter = "raw")] + _write: Option, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "AuthenticationExtensionsPRFInputs")] +struct Prf { + #[webidl(converter = "raw")] + _eval: Option>, + #[webidl(converter = "raw")] + _eval_by_credential: Option>>, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "AuthenticationExtensionsPRFValues")] +struct PrfValues { + #[webidl(required, converter = "raw")] + _first: Buffer, + #[webidl(converter = "raw")] + _second: Option, +} diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs index 2ea2c0b71e..f3f40fe717 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -74,6 +74,7 @@ const NAVIGATOR_RUNTIME_DATA_KEYS: &[&str] = &[ "storageBuckets", "geolocation", "mediaCapabilities", + "credentials", ]; const WORKER_NAVIGATOR_INSTALLED_SLOT: &str = "__moliWorkerNavigatorInstalled"; const WORKER_NAVIGATOR_MATERIALIZING_SLOT: &str = "__moliWorkerNavigatorMaterializing"; @@ -197,6 +198,13 @@ struct NavigatorRuntimeDataPrototypeDeclaration { cookie_enabled: (), } +#[derive(Default, WebApiObject)] +#[webapi(fragment, prototype = "Navigator")] +struct NavigatorCredentialsPrototypeDeclaration { + #[webapi(accessor_property, enumerable, receiver = web_api_interfaces::Navigator::is_instance, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 33))] + credentials: (), +} + #[derive(Default, WebApiFunctionTemplate)] #[webapi(interface = web_api_interfaces::Navigator)] struct NavigatorPrototypeMethodsDeclaration { @@ -591,6 +599,8 @@ struct WindowNavigatorBackingDeclaration<'scope, 'profile> { #[webapi(data_property, enumerable)] media_capabilities: v8::Local<'scope, v8::Value>, + #[webapi(data_property, enumerable)] + credentials: v8::Local<'scope, v8::Value>, } #[derive(WebApiObject)] @@ -927,6 +937,19 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( } } +pub(in crate::context_bootstrap) fn finalize_navigator_credentials_realm_bindings<'s>( + scope: &mut v8::PinScope<'s, '_>, + prototype: v8::Local<'s, v8::Object>, +) -> Result<()> { + let global = scope.get_current_context().global(scope); + if super::super::runtime_state::window_realm_secure_context_available(scope, global) { + NavigatorCredentialsPrototypeDeclaration::default() + .initialize(scope, prototype) + .map_err(|error| anyhow!("failed to initialize Navigator credentials: {error}"))?; + } + Ok(()) +} + fn filter_navigator_secure_context_exposure<'s>( scope: &mut v8::PinScope<'s, '_>, prototype: v8::Local<'s, v8::Object>, @@ -934,6 +957,7 @@ fn filter_navigator_secure_context_exposure<'s>( ) -> Result<()> { if !secure_context { delete_object_property(scope, prototype, "clipboard")?; + delete_object_property(scope, prototype, "credentials")?; delete_object_property(scope, prototype, "mediaDevices")?; delete_object_property(scope, prototype, "storage")?; delete_object_property(scope, prototype, "storageBuckets")?; @@ -1086,6 +1110,7 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>( build_service_worker_container(scope, owner_child, owner_popup)?.into() } NavigatorSubobject::Clipboard => build_clipboard_object(scope)?.into(), + NavigatorSubobject::Credentials => super::credentials::build_container(scope)?.into(), NavigatorSubobject::UserActivation => build_user_activation(scope)?.into(), NavigatorSubobject::StorageBuckets => { build_storage_bucket_manager(scope, owner_child, owner_popup)?.into() @@ -1275,6 +1300,7 @@ fn build_window_navigator_backing_for_owner<'s>( storage_buckets: v8::undefined(scope).into(), geolocation: v8::undefined(scope).into(), media_capabilities: v8::undefined(scope).into(), + credentials: v8::undefined(scope).into(), } .bind(scope) .map_err(|error| anyhow!("failed to bind Navigator backing object: {error}")) @@ -1685,6 +1711,7 @@ fn navigator_runtime_data_object<'s>( scope: &mut v8::PinScope<'s, '_>, navigator: v8::Local<'s, v8::Object>, ) -> Option> { + let navigator = moli_webapi_declare::web_api_object_target(scope, navigator)?; get_private_value(scope, navigator, NAVIGATOR_RUNTIME_DATA_SLOT) .and_then(|value| v8::Local::::try_from(value).ok()) } diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs index 76e3def923..29af01ff78 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs @@ -22,11 +22,12 @@ pub(super) enum NavigatorSubobject { StorageBuckets, Geolocation, MediaCapabilities, + Credentials, } impl NavigatorSubobject { #[cfg(test)] - pub(super) const ALL: [Self; 16] = [ + pub(super) const ALL: [Self; 17] = [ Self::Languages, Self::MimeTypes, Self::Plugins, @@ -43,6 +44,7 @@ impl NavigatorSubobject { Self::StorageBuckets, Self::Geolocation, Self::MediaCapabilities, + Self::Credentials, ]; pub(super) fn from_key(key: &str) -> Option { @@ -63,6 +65,7 @@ impl NavigatorSubobject { "storageBuckets" => Some(Self::StorageBuckets), "geolocation" => Some(Self::Geolocation), "mediaCapabilities" => Some(Self::MediaCapabilities), + "credentials" => Some(Self::Credentials), _ => None, } } @@ -85,6 +88,7 @@ impl NavigatorSubobject { Self::StorageBuckets => "storageBuckets", Self::Geolocation => "geolocation", Self::MediaCapabilities => "mediaCapabilities", + Self::Credentials => "credentials", } } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/activity.rs b/moli-renderer-v8/src/native_bridge/context_host/activity.rs index f17482dcc0..dce14f145c 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/activity.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/activity.rs @@ -1,5 +1,6 @@ use super::workers::WorkerExecutionState; use super::*; +use crate::dom::native::Node; impl JsContextHost { pub(crate) fn document_has_browsing_context(&self, document: DomHandle) -> bool { @@ -14,6 +15,35 @@ impl JsContextHost { .is_some() } + pub(crate) fn document_is_fully_active(&self, document: DomHandle) -> bool { + if self.page_context_resources_closed { + return false; + } + // A retained Window association is insufficient: the Document and each + // ancestor must still be current in a live browsing context. + let mut document = document; + let mut visited = std::collections::HashSet::new(); + loop { + if document == self.document_handle() { + return true; + } + if let Some(popup_id) = self.lightweight_popup_id_for_document_handle(document) { + return self.lightweight_popup_is_open(popup_id); + } + let Some(frame) = self.child_browsing_context_host_for_document_handle(document) else { + return false; + }; + if !visited.insert(frame) || !self.dom_host().is_connected(frame) { + return false; + } + let Some(parent_document) = self.dom_host().node(frame).and_then(Node::owner_document) + else { + return false; + }; + document = parent_document; + } + } + pub(crate) fn document_activity(&self) -> moli_page_types::DocumentActivity { self.document_activity } diff --git a/moli-renderer-v8/src/script_vm/tests/credential_container.js b/moli-renderer-v8/src/script_vm/tests/credential_container.js new file mode 100644 index 0000000000..422b955e66 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/credential_container.js @@ -0,0 +1,262 @@ +(async () => { + const facts = {complete: false, checks: [], traces: []}; + globalThis.__uiEventResults = facts; + const assert = (ok, message) => { if (!ok) throw Error(message); }; + const same = (actual, expected) => assert(JSON.stringify(actual) === JSON.stringify(expected), JSON.stringify({actual, expected})); + const foreign = document.getElementById('child').contentWindow; + for (const [realm, w] of [['main', globalThis], ['iframe', foreign]]) { + const run = async (name, fn) => { + try { await fn(); facts.checks.push({realm, name, passed: true}); } + catch (error) { facts.checks.push({realm, name, passed: false, error: error.name, message: error.message}); } + }; + if (!w.navigator.credentials) { + facts.checks.push({realm, name: 'navigator.credentials producer', passed: false, message: 'Missing producer'}); + continue; + } + const container = w.navigator.credentials; + const prototype = w.CredentialsContainer.prototype; + const PromiseCtor = w.Promise, TypeErrorCtor = w.TypeError, DOMExceptionCtor = w.DOMException; + const methods = Object.fromEntries(['get', 'create', 'store', 'preventSilentAccess'].map(name => [name, prototype[name]])); + const invoke = (name, receiver, args = []) => { + let result; + try { result = methods[name].apply(receiver, args); } + catch (error) { throw Error('Synchronous exception: ' + error); } + assert(result instanceof PromiseCtor, 'Promise must belong to function realm'); + return result; + }; + const rejection = async (name, receiver, args, Constructor, exceptionName) => { + let error, rejected = false; + try { await invoke(name, receiver, args); } catch (caught) { rejected = true; error = caught; } + assert(rejected, 'Expected a rejected Promise'); + if (Constructor) assert(error instanceof Constructor, 'Wrong exception constructor/realm: ' + String(error)); + if (exceptionName) { + assert(error.name === exceptionName, 'Wrong exception name: ' + String(error)); + if (exceptionName === 'NotSupportedError') assert(error.code === 9, 'NotSupportedError code'); + if (exceptionName === 'InvalidStateError') assert(error.code === 11, 'InvalidStateError code'); + } + return error; + }; + const controller = reason => { const value = new w.AbortController(); value.abort(reason); return value; }; + const validCreation = () => ({rp: {name: 'Site'}, user: {name: 'user', displayName: 'User', id: new Uint8Array([1])}, challenge: new Uint8Array([2]), pubKeyCredParams: [{type: 'public-key', alg: -7}]}); + const validRequest = () => ({challenge: new Uint8Array([3])}); + const getters = (values, trace, prefix = '') => Object.defineProperties({}, Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {enumerable: true, get() { trace.push(prefix + key); return value; }}]))); + await run('secure SameObject producer and descriptor', async () => { + assert(w.isSecureContext, 'Fixture must run in a secure context'); + const descriptor = Object.getOwnPropertyDescriptor(w.Navigator.prototype, 'credentials'); + assert(descriptor.enumerable && descriptor.configurable && descriptor.set === undefined, 'Readonly enumerable accessor'); + assert(container === w.navigator.credentials && descriptor.get.call(w.navigator) === container, 'SameObject'); + assert(Object.getPrototypeOf(container) === prototype && container instanceof w.CredentialsContainer, 'Relevant-realm prototype'); + assert(Object.prototype.toString.call(container) === '[object CredentialsContainer]', 'Interface tag'); + let error; try { new w.CredentialsContainer(); } catch (caught) { error = caught; } + assert(error instanceof TypeErrorCtor, 'Illegal constructor'); + const other = w === globalThis ? foreign : globalThis; + assert(descriptor.get.call(other.navigator) === other.navigator.credentials, 'Borrowed Navigator getter uses receiver realm/cache'); + assert(Object.getPrototypeOf(descriptor.get.call(other.navigator)) === other.CredentialsContainer.prototype, 'Borrowed getter materializes in receiver realm'); + }); + await run('Navigator getter uses native brand without author traps', async () => { + const getter = Object.getOwnPropertyDescriptor(w.Navigator.prototype, 'credentials').get; + let reads = 0; + const trap = () => { reads++; throw Error('Getter trap'); }; + const revoked = Proxy.revocable(w.navigator, {}); revoked.revoke(); + for (const fake of [{}, Object.create(w.navigator), Object.create(w.Navigator.prototype), new Proxy(w.navigator, {get: trap, getPrototypeOf: trap}), revoked.proxy, new EventTarget()]) { + let error; try { getter.call(fake); } catch (caught) { error = caught; } + assert(error instanceof TypeErrorCtor, 'Navigator getter accepted a forged receiver'); + } + assert(reads === 0, 'Receiver check ran author traps'); + }); + const trap = () => { throw Error('Unexpected author trap'); }; + const revoked = Proxy.revocable(container, {}); revoked.revoke(); + const badReceivers = [null, undefined, {}, Object.create(container), Object.create(prototype), new Proxy(container, {get: trap, getPrototypeOf: trap}), revoked.proxy, new EventTarget()]; + for (const [name, length] of [['get', 0], ['create', 0], ['store', 1], ['preventSilentAccess', 0]]) { + await run(name + '/native descriptor and arity', async () => { + const descriptor = Object.getOwnPropertyDescriptor(prototype, name); + assert(descriptor.enumerable && descriptor.configurable && descriptor.writable, 'Writable enumerable method'); + assert(methods[name].name === name && methods[name].length === length, 'Name and arity'); + assert(Function.prototype.toString.call(methods[name]).includes('[native code]'), 'Native function'); + }); + for (const [index, receiver] of badReceivers.entries()) { + await run(name + '/brand before argument conversion/' + index, async () => { + let reads = 0; + const argument = new Proxy({}, {get() { reads++; throw Error('Argument conversion ran'); }}); + await rejection(name, receiver, [argument], TypeErrorCtor); + assert(reads === 0, 'Invalid receiver must fail before any argument conversion'); + }); + } + } + for (const name of ['get', 'create']) { + for (const [label, args] of [['omitted', []], ['undefined', [undefined]], ['null', [null]], ['empty', [{}]], ['unknown', [{get unknown() { throw Error('Unknown member read'); }}]]]) { + await run(name + '/empty dictionary/' + label, () => rejection(name, container, args, DOMExceptionCtor, 'NotSupportedError')); + } + for (const [index, value] of [true, 1, 'string', Symbol('dictionary'), 1n].entries()) { + await run(name + '/primitive dictionary/' + index, () => rejection(name, container, [value], TypeErrorCtor)); + } + for (const mediation of ['silent', 'optional', 'conditional', 'required']) { + await run(name + '/valid mediation/' + mediation, async () => { + const marker = {}, signal = controller(marker).signal; + assert(await rejection(name, container, [{mediation, signal}]) === marker, 'Pre-abort reason identity'); + }); + } + for (const [index, mediation] of ['', 'OPTIONAL', null, Symbol('mediation')].entries()) { + await run(name + '/invalid mediation/' + index, () => rejection(name, container, [{mediation, signal: controller({}).signal}], TypeErrorCtor)); + } + for (const [index, reason] of ['reason', {}, [], new Error('reason'), null, 42, 1n, Symbol('reason')].entries()) { + await run(name + '/pre-abort exact reason/' + index, async () => { + const signal = controller(reason).signal; + assert(await rejection(name, container, [{signal}]) === reason, 'Abort reason was replaced'); + }); + } + await run(name + '/default abort reason and foreign signal', async () => { + const other = w === globalThis ? foreign : globalThis; + const abort = new other.AbortController(); abort.abort(); + assert(await rejection(name, container, [{signal: abort.signal}]) === abort.signal.reason, 'Cross-realm native signal/default reason'); + }); + const signal = new w.AbortController().signal; + const revokedSignal = Proxy.revocable(signal, {}); revokedSignal.revoke(); + for (const [index, invalid] of [null, {}, Object.create(signal), Object.create(w.AbortSignal.prototype), new Proxy(signal, {get: trap, getPrototypeOf: trap}), revokedSignal.proxy, new EventTarget()].entries()) { + await run(name + '/AbortSignal native brand/' + index, () => rejection(name, container, [{signal: invalid}], TypeErrorCtor)); + } + await run(name + '/abort state ignores public getters', async () => { + const marker = {}, signal = controller(marker).signal; + Object.defineProperties(signal, {aborted: {get: trap}, reason: {get: trap}}); + assert(await rejection(name, container, [{signal}]) === marker, 'Internal abort state/reason'); + }); + await run(name + '/conversion exception before pre-abort', async () => { + const marker = {}, signal = controller({}).signal; + assert(await rejection(name, container, [{get mediation() { throw marker; }, signal}]) === marker, 'Conversion exception was replaced'); + }); + await run(name + '/all dictionary getters precede native algorithm', async () => { + const trace = [], marker = {}, signal = controller(marker).signal; + const values = name === 'get' ? {federated: undefined, mediation: 'optional', password: false, publicKey: undefined, signal, uiMode: undefined} : {federated: undefined, mediation: 'optional', password: undefined, publicKey: undefined, signal}; + const options = getters(values, trace); + assert(await rejection(name, container, [options]) === marker, 'Abort must follow conversion'); + same(trace, Object.keys(values)); facts.traces.push({realm, name, trace}); + }); + await run(name + '/missing defaults avoid Object.prototype getters', async () => { + let reads = 0; + const get = () => { reads++; throw Error('Inherited default read'); }; + for (const key of ['federated', 'mediation', 'password', 'publicKey', 'signal']) Object.defineProperty(w.Object.prototype, key, {configurable: true, get}); + let error; + try { error = await rejection(name, container, [], DOMExceptionCtor, 'NotSupportedError'); } + finally { for (const key of ['federated', 'mediation', 'password', 'publicKey', 'signal']) delete w.Object.prototype[key]; } + assert(reads === 0 && error.name === 'NotSupportedError', 'Empty default is not an ordinary JS object'); + }); + for (const [index, binary] of [null, [], {}, 'bytes', new Proxy(new Uint8Array([1]), {})].entries()) { + await run(name + '/native BufferSource/' + index, async () => { + const publicKey = name === 'create' ? validCreation() : validRequest(); + publicKey.challenge = binary; + await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor); + }); + } + for (const [index, binary] of [new ArrayBuffer(2), new Uint8Array([1, 2]), new DataView(new ArrayBuffer(3))].entries()) { + await run(name + '/valid BufferSource before abort/' + index, async () => { + const marker = {}, publicKey = name === 'create' ? validCreation() : validRequest(); + publicKey.challenge = binary; + assert(await rejection(name, container, [{publicKey, signal: controller(marker).signal}]) === marker, 'Native ArrayBuffer/view conversion'); + }); + } + for (const [index, binary] of [new ArrayBuffer(2, {maxByteLength: 4}), new Uint8Array(new ArrayBuffer(2, {maxByteLength: 4})), new DataView(new ArrayBuffer(2, {maxByteLength: 4}))].entries()) { + await run(name + '/reject resizable BufferSource/' + index, async () => { + const publicKey = name === 'create' ? validCreation() : validRequest(); publicKey.challenge = binary; + await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor); + }); + } + if (typeof w.SharedArrayBuffer === 'function') { + for (const [index, binary] of [new w.SharedArrayBuffer(2), new Uint8Array(new w.SharedArrayBuffer(2)), new DataView(new w.SharedArrayBuffer(2))].entries()) { + await run(name + '/reject shared BufferSource/' + index, async () => { + const publicKey = name === 'create' ? validCreation() : validRequest(); publicKey.challenge = binary; + await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor); + }); + } + } + for (const extension of [{credBlob: {}}, {largeBlob: {write: {}}}, {prf: {eval: {first: {}}}}, {prf: {evalByCredential: {AA: {first: {}}}}}]) { + await run(name + '/nested extension buffers/' + JSON.stringify(extension), async () => { + const publicKey = name === 'create' ? validCreation() : validRequest(); publicKey.extensions = extension; + await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor); + }); + } + await run(name + '/credential descriptor required members', async () => { + const publicKey = name === 'create' ? validCreation() : validRequest(); + publicKey[name === 'create' ? 'excludeCredentials' : 'allowCredentials'] = [{type: 'public-key'}]; + await rejection(name, container, [{publicKey, signal: controller({}).signal}], TypeErrorCtor); + }); + await run(name + '/PRF record key getter exception identity', async () => { + const marker = {}, publicKey = name === 'create' ? validCreation() : validRequest(); + publicKey.extensions = {prf: {evalByCredential: {get AA() { throw marker; }}}}; + assert(await rejection(name, container, [{publicKey, signal: controller({}).signal}]) === marker, 'Record getter exception identity'); + }); + } + await run('create/inherited dictionaries convert before derived members', async () => { + const marker = {}, trace = [], publicKey = validCreation(); + publicKey.rp = getters({name: 'Site', id: location.hostname}, trace, 'rp.'); + publicKey.user = getters({name: 'user', displayName: 'User', id: new Uint8Array([1])}, trace, 'user.'); + assert(await rejection('create', container, [{publicKey, signal: controller(marker).signal}]) === marker, 'Valid nested conversion'); + same(trace, ['rp.name', 'rp.id', 'user.name', 'user.displayName', 'user.id']); + }); + for (const kind of ['password', 'federated']) { + await run('create/' + kind + '/inherited dictionary member order', async () => { + const marker = {}, trace = []; + const values = kind === 'password' ? {id: 'user', iconURL: '', name: 'User', origin: location.origin, password: 'secret'} : {id: 'user', iconURL: '', name: 'User', origin: location.origin, protocol: undefined, provider: 'https://idp.example'}; + const options = {[kind]: getters(values, trace), signal: controller(marker).signal}; + assert(await rejection('create', container, [options]) === marker, 'Abort after credential conversion'); + same(trace, Object.keys(values)); + }); + await run('create/' + kind + '/required origin', async () => { + const data = kind === 'password' ? {id: 'user', password: 'secret'} : {id: 'user', provider: 'https://idp.example'}; + await rejection('create', container, [{[kind]: data, signal: controller({}).signal}], TypeErrorCtor); + }); + } + await run('create/native form union precedes dictionary getters', async () => { + const marker = {}, form = document.createElement('form'); + Object.defineProperty(form, 'password', {get: trap}); + assert(await rejection('create', container, [{password: form, signal: controller(marker).signal}]) === marker, 'Native form union branch'); + }); + await run('create/multiple credential types before pre-abort', async () => { + const password = {id: 'user', origin: location.origin, password: 'secret'}, publicKey = validCreation(); + await rejection('create', container, [{password, publicKey, signal: controller({}).signal}], DOMExceptionCtor, 'NotSupportedError'); + }); + for (const [index, args] of [[], [undefined], [null], [{}], [Object.create(w.Credential.prototype)]].entries()) { + await run('store/required native Credential/' + index, () => rejection('store', container, args, TypeErrorCtor)); + } + await run('preventSilentAccess/idempotent undefined without argument conversion', async () => { + assert(await invoke('preventSilentAccess', container, [new Proxy({}, {get: trap})]) === undefined, 'No extra argument conversion'); + assert(await invoke('preventSilentAccess', container) === undefined, 'Idempotent result'); + }); + await run('native brand remains after prototype replacement', async () => { + const saved = Object.getPrototypeOf(container); + Object.setPrototypeOf(container, null); + try { assert(await invoke('preventSilentAccess', container) === undefined, 'Native brand survives prototype change'); } + finally { Object.setPrototypeOf(container, saved); } + }); + await run('native Promise and errors ignore replaced public constructors', async () => { + const saved = [w.Promise, w.TypeError, w.DOMException, w.CredentialsContainer]; + try { + w.Promise = w.TypeError = w.DOMException = w.CredentialsContainer = function() { throw Error('Public constructor called'); }; + await rejection('store', container, [{}], TypeErrorCtor); + await rejection('create', container, [], DOMExceptionCtor, 'NotSupportedError'); + assert(await invoke('preventSilentAccess', container) === undefined, 'Native Promise resolver'); + } finally { [w.Promise, w.TypeError, w.DOMException, w.CredentialsContainer] = saved; } + }); + } + for (const ancestor of [false, true]) { + const name = ancestor ? 'inactive ancestor' : 'removed iframe'; + try { + const outer = document.createElement('iframe'); document.body.appendChild(outer); + let frame = outer; + if (ancestor) { frame = outer.contentDocument.createElement('iframe'); outer.contentDocument.body.appendChild(frame); } + const w = frame.contentWindow, container = w.navigator.credentials, PromiseCtor = w.Promise, DOMExceptionCtor = w.DOMException, TypeErrorCtor = w.TypeError; + const marker = {}, abort = new w.AbortController(); abort.abort(marker); + const get = container.get, create = container.create, prevent = container.preventSilentAccess, store = container.store; + outer.remove(); + for (const [method, args] of [[get, [{signal: abort.signal}]], [create, [{signal: abort.signal}]], [prevent, []]]) { + let error; const promise = method.apply(container, args); assert(promise instanceof PromiseCtor, 'Inactive function Promise realm'); + try { await promise; } catch (caught) { error = caught; } + assert(error instanceof DOMExceptionCtor && error.name === 'InvalidStateError', 'Fully active check precedes pre-abort'); + } + let error; try { await store.call(container, {}); } catch (caught) { error = caught; } + assert(error instanceof TypeErrorCtor, 'WebIDL conversion precedes native activity check'); + facts.checks.push({realm: 'lifecycle', name, passed: true}); + } catch (error) { facts.checks.push({realm: 'lifecycle', name, passed: false, error: error.name, message: error.message}); } + } + facts.passed = facts.checks.filter(row => row.passed).length; facts.total = facts.checks.length; facts.complete = true; + return true; +})() diff --git a/moli-renderer-v8/src/script_vm/tests/credential_container.rs b/moli-renderer-v8/src/script_vm/tests/credential_container.rs new file mode 100644 index 0000000000..6cf79b1cad --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/credential_container.rs @@ -0,0 +1,123 @@ +use super::*; + +#[test] +fn credentials_container_preserves_native_brands_conversion_order_abort_and_inactive_realms() { + let mut vm = new_storage_page_task_executor_test_vm("https://credentials-container.test/"); + vm.eval("document.body.innerHTML = ''") + .unwrap(); + vm.eval(include_str!("credential_container.js")).unwrap(); + assert_eq!(vm.eval("__uiEventResults.complete").unwrap(), "true"); + assert_eq!( + vm.eval("JSON.stringify(__uiEventResults.checks.filter(row => !row.passed))") + .unwrap(), + "[]" + ); +} + +#[test] +fn credentials_container_exposes_navigator_property_in_secure_contexts() { + for (url, exposed) in [ + ("https://credentials-container.test/", true), + ("http://localhost/", true), + ("http://credentials-container.test/", false), + ] { + let mut vm = new_storage_page_task_executor_test_vm(url); + assert_eq!(vm.eval("isSecureContext").unwrap(), exposed.to_string()); + vm.eval(&format!( + "Object.defineProperty(globalThis, 'isSecureContext', {{value: {}}})", + !exposed + )) + .unwrap(); + assert_eq!( + vm.eval("Object.hasOwn(Navigator.prototype, 'credentials')") + .unwrap(), + exposed.to_string(), + "{url}" + ); + assert_eq!( + vm.eval("'credentials' in navigator").unwrap(), + exposed.to_string(), + "{url} after Navigator materialization" + ); + } +} + +#[test] +fn credentials_container_accepts_registered_native_proxies_and_native_credential_arguments() { + let mut vm = new_storage_page_task_executor_test_vm("https://credentials-container.test/"); + vm.eval("globalThis.nativeSignal = AbortSignal.abort({})") + .unwrap(); + let context_ptr = &vm.page_default_runtime.context as *const _; + vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| { + let global = scope.get_current_context().global(scope); + let fixtures = v8::Object::new(scope); + for name in ["CredentialsContainer", "Credential", "PublicKeyCredential"] { + let object = v8::Object::new(scope); + // Identity-only fixtures, not produced credentials or saved secrets. + moli_webapi_declare::initialize_web_api_object(scope, object, name).unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, object, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + let key = crate::util::v8str(scope, name); + assert_eq!( + fixtures.create_data_property(scope, key.into(), proxy.into()), + Some(true) + ); + } + let key = crate::util::v8str(scope, "navigator"); + let navigator = global.get(scope, key.into()).unwrap(); + let navigator = v8::Local::::try_from(navigator).unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, navigator, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + let key = crate::util::v8str(scope, "Navigator"); + assert_eq!( + fixtures.create_data_property(scope, key.into(), proxy.into()), + Some(true) + ); + let key = crate::util::v8str(scope, "nativeSignal"); + let signal = global.get(scope, key.into()).unwrap(); + let signal = v8::Local::::try_from(signal).unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, signal, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + let key = crate::util::v8str(scope, "AbortSignal"); + assert_eq!( + fixtures.create_data_property(scope, key.into(), proxy.into()), + Some(true) + ); + let key = crate::util::v8str(scope, "nativeFixtures"); + assert_eq!( + global.create_data_property(scope, key.into(), fixtures.into()), + Some(true) + ); + Ok(()) + }) + .unwrap(); + vm.eval(r#"(async () => { + const prototype = CredentialsContainer.prototype, container = nativeFixtures.CredentialsContainer; + const getter = Object.getOwnPropertyDescriptor(Navigator.prototype, 'credentials').get; + if (getter.call(nativeFixtures.Navigator) !== navigator.credentials) throw Error('Native Navigator proxy rejected'); + if (await prototype.preventSilentAccess.call(container) !== undefined) throw Error('Native proxy receiver rejected'); + for (const method of ['get', 'create']) { + let reason; try { await prototype[method].call(container, {signal: nativeFixtures.AbortSignal}); } catch (caught) { reason = caught; } + if (reason !== nativeSignal.reason) throw Error('Native AbortSignal proxy rejected'); + } + for (const credential of [nativeFixtures.Credential, nativeFixtures.PublicKeyCredential]) { + let error; try { await prototype.store.call(container, credential); } catch (caught) { error = caught; } + if (!(error instanceof DOMException) || error.name !== 'NotSupportedError') throw Error('Native Credential brand rejected'); + let conversions = 0; + const revoked = Proxy.revocable(credential, {}); revoked.revoke(); + for (const fake of [Object.create(credential), new Proxy(credential, {get() { conversions++; throw Error('Trap'); }}), revoked.proxy]) { + let error; try { await prototype.store.call(container, fake); } catch (caught) { error = caught; } + if (!(error instanceof TypeError)) throw Error('Author receiver accepted'); + } + if (conversions) throw Error('Native interface conversion ran author trap'); + } + globalThis.nativeCredentialTestDone = true; + })().catch(error => globalThis.nativeCredentialTestDone = String(error))"#).unwrap(); + assert_eq!( + vm.eval("globalThis.nativeCredentialTestDone").unwrap(), + "true" + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index f5a98ca0c7..06891f0172 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -1,3 +1,4 @@ +mod credential_container; mod credential_json_options; mod writable_stream_native_receivers; use super::post_parse::dynamic_script_execute_is_runnable_before_dom_content_loaded;