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.
This commit is contained in:
ldm0
2026-09-27 07:59:23 +08:00
parent 6cbe3e9898
commit 53914b7a52
3 changed files with 77 additions and 4 deletions
+15 -3
View File
@@ -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));
}
+4 -1
View File
@@ -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,
}),
)
}
@@ -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 {