From 53914b7a5238186c2ada4cdbaa2bfaba79b065ae Mon Sep 17 00:00:00 2001 From: ldm0 Date: Fri, 11 Sep 2026 14:16:45 +0800 Subject: [PATCH] fix(file-api): authorize blob URL revocation by storage key Record the URL creator's storage key and browser partition independently of its backing Blob, and check authorization atomically with URL removal. Use the Worker's environment key and share opaque-origin nonce allocation between Windows and Workers so unrelated opaque origins cannot collide. Validated with cargo fmt, workspace clippy, and nextest (17990 passed, 13 skipped). Cross-global revoke WPT passes 3/3; all 874 contract checks pass, with no regressions across the 68-case WPT suite. --- moli-file-api/src/blob_store.rs | 18 +++++- moli-renderer-v8/src/blob.rs | 5 +- .../src/runtime/page_vm/tests/fetch_xhr.rs | 58 +++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/moli-file-api/src/blob_store.rs b/moli-file-api/src/blob_store.rs index 9b8f67595b..b27c34990e 100644 --- a/moli-file-api/src/blob_store.rs +++ b/moli-file-api/src/blob_store.rs @@ -405,10 +405,22 @@ mod tests { let first_key = "first URL creator".to_owned(); let second_key = "second URL creator".to_owned(); let first = store - .create_object_url_with_access_key(Some(2), blob, "null", Some(first_key.clone())) + .create_object_url_with_lifetime_and_access_key( + Some(2), + Some(101), + blob, + "null", + Some(first_key.clone()), + ) .unwrap(); let second = store - .create_object_url_with_access_key(Some(3), blob, "null", Some(second_key.clone())) + .create_object_url_with_lifetime_and_access_key( + Some(3), + Some(101), + blob, + "null", + Some(second_key.clone()), + ) .unwrap(); let unkeyed = store.create_object_url(Some(1), blob, "null").unwrap(); store.release_blob_wrapper_ref(blob); @@ -427,7 +439,7 @@ mod tests { ); assert!(store.revoke_object_url(&unkeyed)); assert!(store.blob_bytes(blob).is_some()); - assert!(store.revoke_object_url_with_access_key(&second, &second_key)); + assert_eq!(store.cleanup_object_url_lifetime(3, 101), 1); assert!(store.blob_bytes(blob).is_none()); assert!(!store.revoke_object_url_with_access_key(&second, &second_key)); } diff --git a/moli-renderer-v8/src/blob.rs b/moli-renderer-v8/src/blob.rs index b0f545337f..75fc0fe523 100644 --- a/moli-renderer-v8/src/blob.rs +++ b/moli-renderer-v8/src/blob.rs @@ -384,7 +384,10 @@ pub(super) fn create_object_url_for_object<'s>( lifetime_id, blob_id, &origin, - Some(ObjectUrlAccessKey { partition, storage_key }), + Some(ObjectUrlAccessKey { + partition, + storage_key, + }), ) } diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs b/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs index 69ac899ecb..98624e7361 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs @@ -4545,6 +4545,64 @@ async fn request_init_exceptions_preserve_identity_without_fetching_or_consuming }).await; } +#[tokio::test] +async fn blob_url_revocation_uses_window_and_worker_creator_storage_keys() { + run_page_vm_async_test(async move { + for document_url in ["https://example.com/", "data:text/html,opaque-parent"] { + let mut page_vm = test_page_vm_with_document_url(Url::parse(document_url).unwrap()); + let local_executor = page_vm.local_executor.clone(); + let result = local_executor.run(async move { + page_vm.vm_mut().eval(r#" + globalThis.__revocationResult = 'pending'; + (async () => { + const check = (value, message) => { if (!value) throw new Error(message); }; + const source = `onmessage = async event => { + const {action, url} = event.data; + if (action === 'create') postMessage(URL.createObjectURL(new Blob(['payload']))); + if (action === 'revoke') { URL.revokeObjectURL(url); postMessage('done'); } + if (action === 'read') { + try { postMessage(await (await fetch(url)).text()); } + catch (error) { postMessage(error.name); } + } + };`; + const sourceUrl = URL.createObjectURL(new Blob([source])); + const workers = [new Worker(sourceUrl), new Worker('data:text/javascript,' + encodeURIComponent(source))]; + const rpc = (worker, action, url) => new Promise((resolve, reject) => { + worker.onmessage = event => resolve(event.data); + worker.onerror = event => reject(new Error(event.message)); + worker.postMessage({action, url}); + }); + try { + for (let i = 0; i < workers.length; i++) { + const worker = workers[i]; + const url = URL.createObjectURL(new Blob(['payload'])); + await rpc(worker, 'revoke', url); + let body; + try { body = await (await fetch(url)).text(); } + catch (error) { body = error.name; } + check(body === (i === 0 ? 'TypeError' : 'payload'), 'worker revocation authority'); + URL.revokeObjectURL(url); + const childUrl = await rpc(worker, 'create'); + URL.revokeObjectURL(childUrl); + check(await rpc(worker, 'read', childUrl) === (i === 0 ? 'TypeError' : 'payload'), 'parent revocation authority'); + await rpc(worker, 'revoke', childUrl); + check(await rpc(worker, 'read', childUrl) === 'TypeError', 'worker can revoke its own opaque URL'); + } + } finally { + for (const worker of workers) worker.terminate(); + URL.revokeObjectURL(sourceUrl); + } + return 'ok'; + })().then(value => { globalThis.__revocationResult = value; }, error => { globalThis.__revocationResult = String(error); }); + "#)?; + drive_websocket_until_done(&mut page_vm, "String(globalThis.__revocationResult !== 'pending')", "revocation checks should finish").await?; + page_vm.vm_mut().eval("globalThis.__revocationResult") + }).await.expect("blob revocation checks should run on owner lane"); + assert_eq!(result, "ok", "document_url={document_url}"); + } + }).await; +} + #[tokio::test] async fn blob_url_entries_survive_request_cloning_and_xhr_open_in_window_and_worker() { run_page_vm_async_test(async move {