mirror of
https://github.com/lexmount/moli.git
synced 2026-10-09 08:01:05 +00:00
fix(file-api): authorize blob URL revocation by storage key
Store the URL creator’s browser partition and storage key separately from its backing Blob, and check authorization atomically with URL removal. Use the Worker environment storage key and a shared opaque-origin nonce allocator for Windows and Workers.
Source: e090f2b070
This commit is contained in:
@@ -40,10 +40,11 @@ impl<OwnerId, PartitionId> Default for BlobEntries<OwnerId, PartitionId> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ObjectUrlState<OwnerId> {
|
||||
#[derive(Debug)]
|
||||
struct ObjectUrlState<OwnerId, AccessKey> {
|
||||
owner_id: Option<OwnerId>,
|
||||
blob_id: BlobId,
|
||||
access_key: Option<AccessKey>,
|
||||
}
|
||||
|
||||
/// Renderer-neutral Blob and object URL backing store.
|
||||
@@ -52,14 +53,14 @@ struct ObjectUrlState<OwnerId> {
|
||||
/// counts. The embedding layer owns JS wrappers and calls the retain/release
|
||||
/// hooks from its finalizers.
|
||||
#[derive(Debug)]
|
||||
pub struct BlobStore<OwnerId, PartitionId> {
|
||||
pub struct BlobStore<OwnerId, PartitionId, AccessKey = ()> {
|
||||
blobs: Mutex<BlobEntries<OwnerId, PartitionId>>,
|
||||
next_blob_id: AtomicU64,
|
||||
object_urls: Mutex<HashMap<String, ObjectUrlState<OwnerId>>>,
|
||||
object_urls: Mutex<HashMap<String, ObjectUrlState<OwnerId, AccessKey>>>,
|
||||
next_object_url_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl<OwnerId, PartitionId> Default for BlobStore<OwnerId, PartitionId> {
|
||||
impl<OwnerId, PartitionId, AccessKey> Default for BlobStore<OwnerId, PartitionId, AccessKey> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
blobs: Mutex::default(),
|
||||
@@ -70,7 +71,7 @@ impl<OwnerId, PartitionId> Default for BlobStore<OwnerId, PartitionId> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<OwnerId, PartitionId> BlobStore<OwnerId, PartitionId>
|
||||
impl<OwnerId, PartitionId, AccessKey> BlobStore<OwnerId, PartitionId, AccessKey>
|
||||
where
|
||||
OwnerId: Copy + Eq + Hash,
|
||||
PartitionId: Eq,
|
||||
@@ -171,6 +172,18 @@ where
|
||||
owner_id: Option<OwnerId>,
|
||||
blob_id: BlobId,
|
||||
origin: &str,
|
||||
) -> Option<String> {
|
||||
self.create_object_url_with_access_key(owner_id, blob_id, origin, None)
|
||||
}
|
||||
|
||||
/// Create an object URL with its creator environment’s access key.
|
||||
/// The key belongs to the URL, independently of the backing Blob.
|
||||
pub fn create_object_url_with_access_key(
|
||||
&self,
|
||||
owner_id: Option<OwnerId>,
|
||||
blob_id: BlobId,
|
||||
origin: &str,
|
||||
access_key: Option<AccessKey>,
|
||||
) -> Option<String> {
|
||||
self.retain_blob_object_url_ref(blob_id)?;
|
||||
let object_url_id = self
|
||||
@@ -178,16 +191,42 @@ where
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.max(1);
|
||||
let object_url = format!("blob:{origin}/{object_url_id}");
|
||||
self.object_urls
|
||||
.lock()
|
||||
.insert(object_url.clone(), ObjectUrlState { owner_id, blob_id });
|
||||
self.object_urls.lock().insert(
|
||||
object_url.clone(),
|
||||
ObjectUrlState {
|
||||
owner_id,
|
||||
blob_id,
|
||||
access_key,
|
||||
},
|
||||
);
|
||||
Some(object_url)
|
||||
}
|
||||
|
||||
/// Revoke an object URL and release its Blob object-URL reference.
|
||||
pub fn revoke_object_url(&self, url: &str) -> bool {
|
||||
let Some(state) = self.object_urls.lock().remove(url) else {
|
||||
return false;
|
||||
self.revoke_object_url_if(url, |_| true)
|
||||
}
|
||||
|
||||
/// Revoke an object URL only when its creator's key matches the caller's.
|
||||
/// Missing keys are unauthorized; checking and removal are atomic.
|
||||
pub fn revoke_object_url_with_access_key(&self, url: &str, access_key: &AccessKey) -> bool
|
||||
where
|
||||
AccessKey: Eq,
|
||||
{
|
||||
self.revoke_object_url_if(url, |state| state.access_key.as_ref() == Some(access_key))
|
||||
}
|
||||
|
||||
fn revoke_object_url_if(
|
||||
&self,
|
||||
url: &str,
|
||||
is_authorized: impl FnOnce(&ObjectUrlState<OwnerId, AccessKey>) -> bool,
|
||||
) -> bool {
|
||||
let state = {
|
||||
let mut object_urls = self.object_urls.lock();
|
||||
if !object_urls.get(url).is_some_and(is_authorized) {
|
||||
return false;
|
||||
}
|
||||
object_urls.remove(url).expect("authorized entry is locked")
|
||||
};
|
||||
self.release_blob_object_url_ref(state.blob_id);
|
||||
true
|
||||
@@ -292,6 +331,40 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn object_url_access_keys_preserve_unauthorized_entries_and_release_authorized_entries() {
|
||||
let store = BlobStore::<u64, u64, String>::default();
|
||||
let blob = store.create_blob(Some(1), Some(10), b"payload".to_vec(), String::new());
|
||||
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()))
|
||||
.unwrap();
|
||||
let second = store
|
||||
.create_object_url_with_access_key(Some(3), blob, "null", Some(second_key.clone()))
|
||||
.unwrap();
|
||||
let unkeyed = store.create_object_url(Some(1), blob, "null").unwrap();
|
||||
store.release_blob_wrapper_ref(blob);
|
||||
|
||||
assert!(!store.revoke_object_url_with_access_key(&first, &second_key));
|
||||
assert!(!store.revoke_object_url_with_access_key(&second, &first_key));
|
||||
assert!(!store.revoke_object_url_with_access_key(&unkeyed, &first_key));
|
||||
assert!(!store.revoke_object_url_with_access_key(&format!("{first}#fragment"), &first_key));
|
||||
assert_eq!(store.object_url_body_and_type(&first).unwrap().0, "payload");
|
||||
assert!(store.revoke_object_url_with_access_key(&first, &first_key));
|
||||
assert!(!store.revoke_object_url_with_access_key(&first, &first_key));
|
||||
assert!(store.object_url_body_and_type(&first).is_none());
|
||||
assert_eq!(
|
||||
store.object_url_body_and_type(&second).unwrap().0,
|
||||
"payload"
|
||||
);
|
||||
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!(store.blob_bytes(blob).is_none());
|
||||
assert!(!store.revoke_object_url_with_access_key(&second, &second_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_url_retains_blob_until_revoked() {
|
||||
let store = BlobStore::<u64, u64>::default();
|
||||
|
||||
@@ -43,10 +43,18 @@ struct BlobPrototypeDeclaration {
|
||||
r#type: (),
|
||||
}
|
||||
|
||||
static BLOB_STORE: OnceLock<BlobStore<ResourceOwnerId, RendererStoragePartitionIdentity>> =
|
||||
OnceLock::new();
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct ObjectUrlAccessKey {
|
||||
partition: RendererStoragePartitionIdentity,
|
||||
storage_key: moli_storage_key::MoliStorageKey,
|
||||
}
|
||||
|
||||
fn blob_store() -> &'static BlobStore<ResourceOwnerId, RendererStoragePartitionIdentity> {
|
||||
type RendererBlobStore =
|
||||
BlobStore<ResourceOwnerId, RendererStoragePartitionIdentity, ObjectUrlAccessKey>;
|
||||
|
||||
static BLOB_STORE: OnceLock<RendererBlobStore> = OnceLock::new();
|
||||
|
||||
fn blob_store() -> &'static RendererBlobStore {
|
||||
BLOB_STORE.get_or_init(BlobStore::default)
|
||||
}
|
||||
|
||||
@@ -328,15 +336,38 @@ pub(super) fn blob_mime_type_from_object<'s>(
|
||||
pub(super) fn create_object_url_for_object<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
object: v8::Local<'s, v8::Object>,
|
||||
origin: &str,
|
||||
storage_key: moli_storage_key::MoliStorageKey,
|
||||
) -> Option<String> {
|
||||
let blob_id = blob_id_from_object(scope, object)?;
|
||||
let partition = current_blob_storage_partition_identity(scope)?;
|
||||
let owner_id = current_resource_owner_id(scope);
|
||||
blob_store().create_object_url(owner_id, blob_id, origin)
|
||||
let origin = storage_key.origin().to_owned();
|
||||
blob_store().create_object_url_with_access_key(
|
||||
owner_id,
|
||||
blob_id,
|
||||
&origin,
|
||||
Some(ObjectUrlAccessKey {
|
||||
partition,
|
||||
storage_key,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn revoke_object_url(url: &str) {
|
||||
blob_store().revoke_object_url(url);
|
||||
pub(super) fn revoke_object_url(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
url: &str,
|
||||
storage_key: moli_storage_key::MoliStorageKey,
|
||||
) {
|
||||
let Some(partition) = current_blob_storage_partition_identity(scope) else {
|
||||
return;
|
||||
};
|
||||
blob_store().revoke_object_url_with_access_key(
|
||||
url,
|
||||
&ObjectUrlAccessKey {
|
||||
partition,
|
||||
storage_key,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn object_url_body_and_type(url: &str) -> Option<(String, String)> {
|
||||
|
||||
@@ -166,16 +166,7 @@ pub(super) fn url_create_object_url_callback<'s>(
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
mut rv: v8::ReturnValue<'_, v8::Value>,
|
||||
) {
|
||||
let origin = if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) {
|
||||
let active_child_handle = current_child_context_handle(scope)
|
||||
.or_else(|| crate::native_bridge::active_child_window_handle(scope));
|
||||
unsafe { &mut *host_ptr }
|
||||
.active_storage_context(scope, active_child_handle)
|
||||
.origin()
|
||||
.to_owned()
|
||||
} else if let Some(worker_url) = current_worker_script_url(scope) {
|
||||
moli_url::origin_ascii_serialization(&worker_url)
|
||||
} else {
|
||||
let Some(storage_key) = current_object_url_storage_key(scope) else {
|
||||
rv.set(v8::undefined(scope).into());
|
||||
return;
|
||||
};
|
||||
@@ -186,7 +177,7 @@ pub(super) fn url_create_object_url_callback<'s>(
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(url) = blob::create_object_url_for_object(scope, object, &origin) else {
|
||||
let Some(url) = blob::create_object_url_for_object(scope, object, storage_key) else {
|
||||
throw_type_error(
|
||||
scope,
|
||||
"Failed to execute 'createObjectURL' on 'URL': parameter 1 is not of type 'Blob'.",
|
||||
@@ -200,6 +191,22 @@ pub(super) fn url_create_object_url_callback<'s>(
|
||||
}
|
||||
}
|
||||
|
||||
fn current_object_url_storage_key(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
) -> Option<moli_storage_key::MoliStorageKey> {
|
||||
if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) {
|
||||
let active_child_handle = current_child_context_handle(scope)
|
||||
.or_else(|| crate::native_bridge::active_child_window_handle(scope));
|
||||
return Some(
|
||||
unsafe { &mut *host_ptr }
|
||||
.active_storage_context(scope, active_child_handle)
|
||||
.storage_key()
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
crate::worker::worker_storage_key(scope)
|
||||
}
|
||||
|
||||
fn current_child_context_handle(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
) -> Option<crate::document_runtime::DomHandle> {
|
||||
@@ -230,8 +237,10 @@ pub(super) fn url_revoke_object_url_callback<'s>(
|
||||
let Some(parsed) = webidl::parse_args::<UrlRevokeObjectUrlArgs>(scope, &args) else {
|
||||
return;
|
||||
};
|
||||
if parsed.url.starts_with("blob:") {
|
||||
blob::revoke_object_url(&parsed.url);
|
||||
if parsed.url.starts_with("blob:")
|
||||
&& let Some(storage_key) = current_object_url_storage_key(scope)
|
||||
{
|
||||
blob::revoke_object_url(scope, &parsed.url, storage_key);
|
||||
}
|
||||
rv.set_undefined();
|
||||
}
|
||||
|
||||
@@ -177,7 +177,6 @@ struct RendererBrowserContextRuntimeInner {
|
||||
shared_worker_runtime: shared_workers::LazySharedWorkerRuntime,
|
||||
service_worker_runtime: service_worker_runtime::LazyServiceWorkerRuntime,
|
||||
storage_partition_identity: RendererStoragePartitionIdentity,
|
||||
next_web_storage_opaque_context_nonce: AtomicU64,
|
||||
next_child_document_loader_id: AtomicU64,
|
||||
next_detached_parser_script_fetch_id: AtomicU64,
|
||||
next_dedicated_worker_instance_id: AtomicU64,
|
||||
@@ -535,7 +534,6 @@ impl RendererBrowserContextRuntime {
|
||||
shared_worker_runtime,
|
||||
service_worker_runtime,
|
||||
storage_partition_identity,
|
||||
next_web_storage_opaque_context_nonce: AtomicU64::default(),
|
||||
next_child_document_loader_id: AtomicU64::default(),
|
||||
next_detached_parser_script_fetch_id: AtomicU64::default(),
|
||||
next_dedicated_worker_instance_id: AtomicU64::default(),
|
||||
@@ -703,12 +701,11 @@ impl RendererBrowserContextRuntime {
|
||||
pub(crate) fn next_web_storage_opaque_context_nonce(
|
||||
&self,
|
||||
) -> moli_storage_key::OpaqueOriginNonce {
|
||||
moli_storage_key::OpaqueOriginNonce::new(
|
||||
self.inner
|
||||
.next_web_storage_opaque_context_nonce
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.saturating_add(1),
|
||||
)
|
||||
// Windows and Workers compare storage keys in the same partition.
|
||||
// Their opaque origins must draw from the same nonce namespace.
|
||||
self.inner
|
||||
.broadcast_channel_registry
|
||||
.next_opaque_context_nonce()
|
||||
}
|
||||
|
||||
pub(crate) fn allocate_child_document_loader_id(&self) -> String {
|
||||
|
||||
@@ -6673,3 +6673,61 @@ async fn worker_blob_url_survives_immediate_revoke_like_chromium() {
|
||||
})
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -28509,3 +28509,35 @@ fn automation_override_preserves_the_native_webdriver_baseline() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_url_revocation_respects_browser_partitions_and_allows_same_origin_realms() {
|
||||
for document_url in ["https://blob-url-revocation.test/", "data:text/html,opaque"] {
|
||||
let markup = "<!doctype html><html><body></body></html>";
|
||||
let mut creator = new_parsed_test_vm(document_url, markup);
|
||||
let mut other_partition = new_parsed_test_vm(document_url, markup);
|
||||
let url = creator
|
||||
.eval("URL.createObjectURL(new Blob(['payload']))")
|
||||
.expect("create object URL");
|
||||
let url_literal = serde_json::to_string(&url).unwrap();
|
||||
other_partition
|
||||
.eval(&format!("URL.revokeObjectURL({url_literal})"))
|
||||
.expect("foreign partition revocation is a silent no-op");
|
||||
assert_eq!(
|
||||
crate::blob::object_url_body_and_type(&url).unwrap().0,
|
||||
"payload"
|
||||
);
|
||||
// The initial about:blank child inherits even its opaque parent's key.
|
||||
creator
|
||||
.eval(&format!(
|
||||
r#"(() => {{
|
||||
const frame = document.createElement('iframe');
|
||||
document.body.appendChild(frame);
|
||||
const revoke = frame.contentWindow.URL.revokeObjectURL;
|
||||
revoke.call(null, {url_literal});
|
||||
}})()"#
|
||||
))
|
||||
.expect("same-origin child can revoke parent URL");
|
||||
assert!(crate::blob::object_url_body_and_type(&url).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user