From 0364afd264cf7428bd2b69758515bd5374287d82 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 14 Sep 2026 21:53:56 +0800 Subject: [PATCH] fix(service-worker): apply WebIDL promise operation semantics Use declarative receiver checks and promise rejection adapters for container registration and lookup methods. Convert options before invoking the Trusted Types default policy and preserve the original conversion exceptions. Correct getRegistration's optional-argument length and cover conversion order, native receiver identity, and cross-realm Promise rejection. --- .../navigator_runtime/navigator.rs | 27 ++- .../window_runtime/service_worker.rs | 36 ++-- .../src/script_vm/tests/browser_api/misc.rs | 80 ++++++-- .../tests/browser_api/trusted_types.rs | 173 ++++++++++++++++-- .../src/script_vm/tests/webidl_receivers.rs | 98 ++++++++++ 5 files changed, 363 insertions(+), 51 deletions(-) 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 7eaa51f617..19f9e07116 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -416,13 +416,34 @@ struct ServiceWorkerContainerDeclaration { #[webapi(accessor_property = "controller", enumerable, getter = navigator_service_worker_controller_getter_callback)] controller: (), - #[webapi(method, enumerable, callback = navigator_service_worker_register_callback, length = 1)] + #[webapi( + method, + enumerable, + callback = navigator_service_worker_register_callback, + receiver = web_api_interfaces::ServiceWorkerContainer::is_instance, + returns_promise, + length = 1 + )] register: (), - #[webapi(method, enumerable, callback = navigator_service_worker_get_registration_callback, length = 1)] + #[webapi( + method, + enumerable, + callback = navigator_service_worker_get_registration_callback, + receiver = web_api_interfaces::ServiceWorkerContainer::is_instance, + returns_promise, + length = 0 + )] get_registration: (), - #[webapi(method, enumerable, callback = navigator_service_worker_get_registrations_callback, length = 0)] + #[webapi( + method, + enumerable, + callback = navigator_service_worker_get_registrations_callback, + receiver = web_api_interfaces::ServiceWorkerContainer::is_instance, + returns_promise, + length = 0 + )] get_registrations: (), #[webapi(method, enumerable, callback = simple_event_target_add_event_listener_callback)] diff --git a/moli-renderer-v8/src/context_bootstrap/window_runtime/service_worker.rs b/moli-renderer-v8/src/context_bootstrap/window_runtime/service_worker.rs index 25bd9407de..02186b0717 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_runtime/service_worker.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_runtime/service_worker.rs @@ -383,6 +383,30 @@ pub(in crate::context_bootstrap) fn navigator_service_worker_register_callback<' args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { + if args.length() == 0 { + throw_type_error( + scope, + "Failed to execute 'register' on 'ServiceWorkerContainer': 1 argument required, but only 0 present.", + ); + return; + } + // Convert the union argument before the options dictionary. The Trusted + // Types sink algorithm runs only after all WebIDL argument conversion. + let script_argument = args.get(0); + let script_argument = match v8::Local::::try_from(script_argument) { + Ok(object) if web_api_interfaces::TrustedScriptURL::is_instance(scope, object) => { + script_argument + } + _ => { + let Some(script) = script_argument.to_string(scope) else { + return; + }; + script.into() + } + }; + let Some(options) = service_worker_registration_options(scope, &args) else { + return; + }; let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { let Some(resolver) = v8::PromiseResolver::new(scope) else { return; @@ -392,13 +416,6 @@ pub(in crate::context_bootstrap) fn navigator_service_worker_register_callback<' rv.set(promise.into()); return; }; - if args.length() == 0 { - throw_type_error( - scope, - "Failed to execute 'register' on 'ServiceWorkerContainer': 1 argument required, but only 0 present.", - ); - return; - } let owner = service_worker_container_owner_scope(scope, args.this()); let Some(request_context) = (unsafe { &mut *host_ptr }).service_worker_window_request_context(owner) @@ -432,16 +449,13 @@ pub(in crate::context_bootstrap) fn navigator_service_worker_register_callback<' }; let Some(script) = crate::context_bootstrap::trusted_script_url_string_or_throw( scope, - args.get(0), + script_argument, requirements, "ServiceWorkerContainer register", "register", ) else { return; }; - let Some(options) = service_worker_registration_options(scope, &args) else { - return; - }; let Some(resolver) = v8::PromiseResolver::new(scope) else { return; }; 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 eb5c9c4e83..5e7ea1fd21 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 @@ -13437,7 +13437,7 @@ async fn navigator_service_worker_shim_controls_window_fetch() { let expected_fetch_url = format!("{base_url}/app/api/data.txt"); let expected_out_of_scope_url = format!("{base_url}/outside/data.txt"); let expected_result = format!( - r#"{{"containerType":"object","controllerIsNull":true,"readyType":"object","serviceWorkerConstructorType":"function","registrationConstructorType":"function","registerType":"function","registerName":"register","registerLength":1,"getRegistrationType":"function","getRegistrationName":"getRegistration","getRegistrationLength":1,"getRegistrationsType":"function","getRegistrationsName":"getRegistrations","getRegistrationsLength":0,"removeEventListenerType":"function","controllerChangeHandlerType":"object","controllerChangeLog":["listener:controllerchange:true","handler:controllerchange:true"],"registration":true,"registrations":"true|0","afterRegisterController":true,"controllerStable":true,"controllerMatchesActive":true,"controllerBrand":true,"registrationBrand":true,"registrationTag":"[object ServiceWorkerRegistration]","installingBrand":false,"installingScriptURL":null,"installingState":null,"waitingIsNull":true,"activeIsNull":false,"activeBrand":true,"activeScriptURL":"{expected_worker_url}","activeState":"activated","registrationScope":"{expected_scope}","readyMatchesRegister":true,"responseStatusText":"Handled by worker","responseText":"sw:/app/api/data.txt","dataResponseStatusText":"OK","dataResponseContentType":"text/plain","dataResponseText":"data-url","outOfScopeResult":"resolved:Handled by worker:sw:/outside/data.txt","firstUnregister":true,"secondUnregister":false,"controllerRetained":true,"controllerStateAfterUnregister":"activated","registrationRemoved":true,"afterUnregisterText":"sw:/app/after-unregister.txt"}}"# + r#"{{"containerType":"object","controllerIsNull":true,"readyType":"object","serviceWorkerConstructorType":"function","registrationConstructorType":"function","registerType":"function","registerName":"register","registerLength":1,"getRegistrationType":"function","getRegistrationName":"getRegistration","getRegistrationLength":0,"getRegistrationsType":"function","getRegistrationsName":"getRegistrations","getRegistrationsLength":0,"removeEventListenerType":"function","controllerChangeHandlerType":"object","controllerChangeLog":["listener:controllerchange:true","handler:controllerchange:true"],"registration":true,"registrations":"true|0","afterRegisterController":true,"controllerStable":true,"controllerMatchesActive":true,"controllerBrand":true,"registrationBrand":true,"registrationTag":"[object ServiceWorkerRegistration]","installingBrand":false,"installingScriptURL":null,"installingState":null,"waitingIsNull":true,"activeIsNull":false,"activeBrand":true,"activeScriptURL":"{expected_worker_url}","activeState":"activated","registrationScope":"{expected_scope}","readyMatchesRegister":true,"responseStatusText":"Handled by worker","responseText":"sw:/app/api/data.txt","dataResponseStatusText":"OK","dataResponseContentType":"text/plain","dataResponseText":"data-url","outOfScopeResult":"resolved:Handled by worker:sw:/outside/data.txt","firstUnregister":true,"secondUnregister":false,"controllerRetained":true,"controllerStateAfterUnregister":"activated","registrationRemoved":true,"afterUnregisterText":"sw:/app/after-unregister.txt"}}"# ); assert_eq!(result, expected_result); @@ -23984,14 +23984,6 @@ async fn navigator_service_worker_url_arguments_follow_webidl_and_origin_rules() () => "resolved", error => error && error.name ); - const synchronousErrorName = callback => { - try { - callback(); - return "none"; - } catch (error) { - return error && error.name; - } - }; globalThis.__serviceWorkerUrlArgumentProbe = { state: "pending" }; (async () => { const registration = await sw.register("/worker.js", { scope: "null" }); @@ -24005,17 +23997,17 @@ async fn navigator_service_worker_url_arguments_follow_webidl_and_origin_rules() const nullScope = await rejectionName( sw.register("/resources/worker.js", { scope: null }) ); - const nullType = synchronousErrorName( - () => sw.register("/worker.js", { type: null }) + const nullType = await rejectionName( + sw.register("/worker.js", { type: null }) ); - const nullUpdateViaCache = synchronousErrorName( - () => sw.register("/worker.js", { updateViaCache: null }) + const nullUpdateViaCache = await rejectionName( + sw.register("/worker.js", { updateViaCache: null }) ); - const primitiveOptions = synchronousErrorName( - () => sw.register("/worker.js", 1) + const primitiveOptions = await rejectionName( + sw.register("/worker.js", 1) ); - const symbolClient = synchronousErrorName( - () => sw.getRegistration(Symbol("client")) + const symbolClient = await rejectionName( + sw.getRegistration(Symbol("client")) ); const unregistered = await registration.unregister(); globalThis.__serviceWorkerUrlArgumentProbe = { @@ -24060,6 +24052,60 @@ async fn navigator_service_worker_url_arguments_follow_webidl_and_origin_rules() .expect("service worker URL argument script server should finish"); } +#[test] +fn navigator_service_worker_argument_errors_reject_with_the_original_exception() { + let mut vm = new_storage_test_vm("https://service-worker-webidl.test/"); + vm.eval( + r#" +(() => { + const sw = navigator.serviceWorker; + const marker = {sentinel: true}; + globalThis.serviceWorkerArgumentFailures = []; + globalThis.serviceWorkerArgumentRejections = 0; + function check(callback, expected) { + const promise = callback(); + if (!(promise instanceof Promise)) serviceWorkerArgumentFailures.push('not a Promise'); + promise.then( + () => serviceWorkerArgumentFailures.push('resolved'), + error => { + serviceWorkerArgumentRejections++; + if (expected === marker ? error !== marker : !(error instanceof TypeError)) { + serviceWorkerArgumentFailures.push('wrong rejection reason'); + } + } + ); + } + check(() => sw.register(), TypeError); + check(() => sw.register(Symbol('script')), TypeError); + check(() => sw.register('https://[', {scope: Symbol('scope')}), TypeError); + check(() => sw.getRegistration({toString() { throw marker; }}), marker); + const steps = ['script', 'scope-get', 'scope-string', 'type-get', 'type-string', 'cache-get', 'cache-string']; + for (let index = 0; index < steps.length; index++) { + const log = []; + const step = name => { log.push(name); if (name === steps[index]) throw marker; }; + check(() => sw.register( + {toString() { step('script'); return 'https://['; }}, + { + get scope() { step('scope-get'); return {toString() { step('scope-string'); return './'; }}; }, + get type() { step('type-get'); return {toString() { step('type-string'); return 'classic'; }}; }, + get updateViaCache() { step('cache-get'); return {toString() { step('cache-string'); return 'imports'; }}; } + } + ), marker); + if (JSON.stringify(log) !== JSON.stringify(steps.slice(0, index + 1))) { + serviceWorkerArgumentFailures.push('conversion order: ' + log); + } + } +})() +"#, + ) + .expect("ServiceWorkerContainer conversion errors should return Promises"); + assert_eq!( + vm.eval("JSON.stringify([serviceWorkerArgumentRejections, serviceWorkerArgumentFailures])") + .unwrap(), + "[11,[]]" + ); +} + #[test] fn window_event_source_constructor_exposes_connecting_instance() { let mut vm = new_storage_test_vm("https://example.com/"); diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/trusted_types.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/trusted_types.rs index b9fa7ef997..20facbeb0b 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/trusted_types.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/trusted_types.rs @@ -2909,27 +2909,19 @@ fn service_worker_register_gates_script_url_before_url_resolution() { let mut vm = new_storage_test_vm("https://service-worker-register-trusted-types.test/"); vm.set_response_content_security_policies(&["require-trusted-types-for 'script'".to_owned()]); - let result = vm - .eval( + vm.eval( r#" (() => { - const errorName = callback => { - try { - callback(); - return "none"; - } catch (error) { - return error && error.name; - } - }; + const errorName = promise => promise.then(() => 'resolved', error => error && error.name); const policy = trustedTypes.createPolicy("service-worker-register", { createHTML: value => value, createScriptURL: value => value }); - const blockedString = errorName(() => navigator.serviceWorker.register("worker.js")); - const blockedWrongType = errorName(() => + const blockedString = errorName(navigator.serviceWorker.register("worker.js")); + const blockedWrongType = errorName( navigator.serviceWorker.register(policy.createHTML("worker.js")) ); - const missing = errorName(() => navigator.serviceWorker.register()); + const missing = errorName(navigator.serviceWorker.register()); const trustedPromise = navigator.serviceWorker.register( policy.createScriptURL("http://[") @@ -2946,21 +2938,162 @@ fn service_worker_register_gates_script_url_before_url_resolution() { const defaultPromise = navigator.serviceWorker.register("worker.potato"); defaultPromise.catch(() => {}); - return JSON.stringify({ - blockedString, - blockedWrongType, - missing, - trustedPromise: trustedPromise instanceof Promise, - defaultPromise: defaultPromise instanceof Promise, - defaultCalls + Promise.all([blockedString, blockedWrongType, missing]).then(([blockedString, blockedWrongType, missing]) => { + globalThis.serviceWorkerTrustedUrlResult = { + blockedString, + blockedWrongType, + missing, + trustedPromise: trustedPromise instanceof Promise, + defaultPromise: defaultPromise instanceof Promise, + defaultCalls + }; }); })() "#, ) .expect("ServiceWorkerContainer.register TrustedScriptURL probe should evaluate"); + let result = vm + .eval("JSON.stringify(globalThis.serviceWorkerTrustedUrlResult)") + .expect("ServiceWorkerContainer.register errors should reject their Promises"); assert_eq!( result, r#"{"blockedString":"TypeError","blockedWrongType":"TypeError","missing":"TypeError","trustedPromise":true,"defaultPromise":true,"defaultCalls":[["worker.potato","TrustedScriptURL","ServiceWorkerContainer register"]]}"# ); } + +#[test] +fn service_worker_register_converts_options_before_the_default_policy() { + let mut vm = new_storage_test_vm("https://service-worker-register-options.test/"); + vm.set_response_content_security_policies(&["require-trusted-types-for 'script'".to_owned()]); + vm.eval( + r#" +(async () => { + const sw = navigator.serviceWorker; + const marker = {sentinel: true}; + const rows = []; + async function probe(label, callback, log) { + const promise = callback(); + log.push('returned'); + try { + await promise; + rows.push([label, 'fulfilled']); + } catch (error) { + rows.push([label, promise instanceof Promise, error === marker ? 'sentinel' : error.name, [...log]]); + } + } + const options = log => ({ + get scope() { log.push('scope'); return './'; }, + get type() { log.push('type'); return 'classic'; }, + get updateViaCache() { log.push('cache'); return 'imports'; } + }); + let log = []; + await probe('blocked-after-options', () => sw.register( + {toString() { log.push('script'); return 'worker.js'; }}, options(log) + ), log); + log = []; + await probe('options-exception-before-policy', () => sw.register('worker.js', { + get scope() { log.push('scope'); throw marker; } + }), log); + log = []; + await probe('script-exception', () => sw.register( + {toString() { log.push('script'); throw marker; }}, options(log) + ), log); + const policy = trustedTypes.createPolicy('registration-tests', {createScriptURL: v => v}); + const trusted = policy.createScriptURL('https://['); + trusted.toString = () => { throw marker; }; + log = []; + await probe('trusted-input', () => sw.register(trusted, options(log)), log); + let defaultThrows = false; + let policyLog = []; + trustedTypes.createPolicy('default', { + createScriptURL(value, type, sink) { + policyLog.push(['policy', value, type, sink]); + if (defaultThrows) throw marker; + return 'https://['; + } + }); + log = []; policyLog = log; + await probe('default-after-options', () => sw.register( + {toString() { log.push('script'); return 'worker\ud800.js'; }}, options(log) + ), log); + log = []; policyLog = log; defaultThrows = true; + await probe('default-exception', () => sw.register('worker.js', options(log)), log); + log = []; policyLog = log; + await probe('invalid-options-before-default', () => sw.register('worker.js', { + get type() { log.push('type'); return null; } + }), log); + return rows; +})().then(rows => { globalThis.serviceWorkerOptionsResult = rows; }); +"#, + ) + .expect("ServiceWorkerContainer.register argument and policy probes should evaluate"); + let result = vm + .eval("JSON.stringify(globalThis.serviceWorkerOptionsResult)") + .unwrap(); + assert_eq!( + serde_json::from_str::(&result).unwrap(), + serde_json::json!([ + [ + "blocked-after-options", + true, + "TypeError", + ["script", "scope", "type", "cache", "returned"] + ], + [ + "options-exception-before-policy", + true, + "sentinel", + ["scope", "returned"] + ], + ["script-exception", true, "sentinel", ["script", "returned"]], + [ + "trusted-input", + true, + "TypeError", + ["scope", "type", "cache", "returned"] + ], + [ + "default-after-options", + true, + "TypeError", + [ + "script", + "scope", + "type", + "cache", + [ + "policy", + "worker�.js", + "TrustedScriptURL", + "ServiceWorkerContainer register" + ], + "returned" + ] + ], + [ + "default-exception", + true, + "sentinel", + [ + "scope", + "type", + "cache", + [ + "policy", + "worker.js", + "TrustedScriptURL", + "ServiceWorkerContainer register" + ], + "returned" + ] + ], + [ + "invalid-options-before-default", + true, + "TypeError", + ["type", "returned"] + ] + ]) + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/webidl_receivers.rs b/moli-renderer-v8/src/script_vm/tests/webidl_receivers.rs index 90009b7c52..d730c0ea5e 100644 --- a/moli-renderer-v8/src/script_vm/tests/webidl_receivers.rs +++ b/moli-renderer-v8/src/script_vm/tests/webidl_receivers.rs @@ -190,3 +190,101 @@ if (parentGetLoaded.call(face) !== face.loaded) realmFailures.push('cross-realm "[2,[]]" ); } + +#[test] +fn webidl_receiver_service_worker_operations_reject_before_argument_conversion() { + let mut vm = new_storage_test_vm("https://service-worker-receiver.test/"); + vm.eval( + r#" +(() => { + const sw = navigator.serviceWorker; + const register = sw.register; + const getRegistration = sw.getRegistration; + const getRegistrations = sw.getRegistrations; + globalThis.serviceWorkerReceiverFailures = []; + globalThis.serviceWorkerReceiverRejections = 0; + let conversions = 0; + const script = {toString() { conversions++; return 'https://['; }}; + const options = {get scope() { conversions++; return './'; }}; + function check(promise) { + if (!(promise instanceof Promise)) serviceWorkerReceiverFailures.push('not a Promise'); + promise.then( + () => serviceWorkerReceiverFailures.push('resolved'), + error => { + serviceWorkerReceiverRejections++; + if (!(error instanceof TypeError)) serviceWorkerReceiverFailures.push('wrong error'); + } + ); + } + for (const receiver of [{}, Object.create(sw), new Proxy(sw, {}), null]) { + check(register.call(receiver, script, options)); + check(getRegistration.call(receiver, script)); + check(getRegistrations.call(receiver)); + } + if (conversions !== 0) serviceWorkerReceiverFailures.push('converted invalid receiver arguments'); + const prototype = Object.getPrototypeOf(sw); + Object.setPrototypeOf(sw, null); + check(register.call(sw, script)); + Object.setPrototypeOf(sw, prototype); + if (conversions !== 1) serviceWorkerReceiverFailures.push('lost native receiver identity'); + if (register.length !== 1 || getRegistration.length !== 0 || getRegistrations.length !== 0) { + serviceWorkerReceiverFailures.push('operation length'); + } +})() +"#, + ) + .expect("ServiceWorkerContainer receiver checks should return rejected Promises"); + assert_eq!( + vm.eval("JSON.stringify([serviceWorkerReceiverRejections, serviceWorkerReceiverFailures])") + .unwrap(), + "[13,[]]" + ); +} + +#[test] +fn webidl_receiver_service_worker_promises_use_the_callee_realm() { + let mut vm = new_storage_test_vm("https://service-worker-receiver.test/"); + vm.eval( + r#" +(() => { + const html = document.appendChild(document.createElement('html')); + html.appendChild(document.createElement('body')); + const child = document.body.appendChild(document.createElement('iframe')).contentWindow; + const parentSw = navigator.serviceWorker; + const childSw = child.navigator.serviceWorker; + const marker = {sentinel: true}; + const throwingScript = {toString() { throw marker; }}; + globalThis.serviceWorkerRealmFailures = []; + globalThis.serviceWorkerRealmRejections = 0; + for (const [callback, P, E, expected] of [ + [() => childSw.register.call(parentSw, Symbol('script')), child.Promise, child.TypeError, null], + [() => parentSw.register.call(childSw, Symbol('script')), Promise, TypeError, null], + [() => childSw.getRegistration.call(parentSw, Symbol('client')), child.Promise, child.TypeError, null], + [() => childSw.register.call({}), child.Promise, child.TypeError, null], + [() => childSw.register.call(parentSw, throwingScript), child.Promise, child.TypeError, marker], + [() => parentSw.register.call(childSw, throwingScript), Promise, TypeError, marker] + ]) { + const promise = callback(); + const otherP = P === Promise ? child.Promise : Promise; + const otherE = E === TypeError ? child.TypeError : TypeError; + if (!(promise instanceof P) || promise instanceof otherP) serviceWorkerRealmFailures.push('Promise realm'); + promise.then( + () => serviceWorkerRealmFailures.push('resolved'), + error => { + serviceWorkerRealmRejections++; + if (expected === marker ? error !== marker : (!(error instanceof E) || error instanceof otherE)) { + serviceWorkerRealmFailures.push('rejection identity or realm'); + } + } + ); + } +})() +"#, + ) + .expect("ServiceWorkerContainer cross-realm operations should return Promises"); + assert_eq!( + vm.eval("JSON.stringify([serviceWorkerRealmRejections, serviceWorkerRealmFailures])") + .unwrap(), + "[6,[]]" + ); +}