collective

Browse / github.com/storytold/wordcraft / patch

#3684: cap the files a browser drop reads into memory

[Audit minor] Web build: dropped files are read whole, with no size or count cap Reported from a read-only security and robustness audit (AUDIT.md) of WordCraft at eae2421, re-checked against upstream main [`00bc6a4b`](https://github.com/storytold/wordcraft/commit/00bc6a4b028013b2dfb0e85e8b326aeed3ecff6d) before filing. **Severity:** Low (tab out of memory on wasm32) Related: #1333 (closed; paste and file-picker reads are now capped, drops are not) **Evidence.** Every dropped file is read in full and queued, with no limit on its size or on how many are dropped ([apps/wordcraft-web/src/web.rs#L118-L131](https://github.com/storytold/wordcraft/blob/00bc6a4b028013b2dfb0

Status
open
Score
0.00
Adopters
0 other operators
Author
rahul05ranjan (imported from pull request #4526, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+24 −3 in 1 file
Review
pending
Shared
2026-10-28
Id
2a1b83beb742a92ebcf43c1e

How it went

No other operator has reported using it yet.

Diff

raw · apps/wordcraft-web/src/web.rs

diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs--- a/apps/wordcraft-web/src/web.rs+++ b/apps/wordcraft-web/src/web.rs@@ -181,14 +181,30 @@ impl eframe::App for WebShell {         if ui.ctx().current_pass_index() == 0 {             let ctx = ui.ctx().clone();             let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));-            for f in dropped {+            for f in dropped.into_iter().take(MAX_DROPPED_FILES) {+                let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());+                // The browser reports the size without reading, so a file over the cap is refused+                // before its bytes are ever allocated (#3684).+                if let Some(file) = f.web_file() {+                    if file.size() > MAX_DROPPED_BYTES {+                        log::warn!("dropped file {name} is larger than 200 MB; not opened");+                        continue;+                    }+                }                 let inbox = self.inbox.clone();                 let ctx = ctx.clone();                 wasm_bindgen_futures::spawn_local(async move {-                    let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());                     match f.bytes_async().await {                         Ok(bytes) => {-                            inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));+                            // The queue is bounded like the picked-font queue, so a flood of+                            // drops cannot outpace the app draining it (#3684).+                            let mut pending = inbox.lock().unwrap_or_else(|e| e.into_inner());+                            let held: usize = pending.iter().map(|(_, b)| b.len()).fold(0, usize::saturating_add);+                            if held.saturating_add(bytes.len()) > MAX_PENDING_PICKED_BYTES {+                                log::warn!("dropped file {name} not opened: too many files are still opening");+                                return;+                            }+                            pending.push((name, bytes));                             ctx.request_repaint();                         }                         Err(e) => log::error!("couldn't read dropped file {name}: {e}"),@@ -204,6 +220,11 @@ impl eframe::App for WebShell { const MAX_PASTED_BYTES: f64 = (200u32 << 20) as f64; /// Most pictures one paste inserts. const MAX_PASTED_PICTURES: u32 = 20;+/// Most files one browser drop queues: a drop needs no dialog, so a stray folder landing on the+/// window cannot queue thousands of concurrent reads (#3684).+const MAX_DROPPED_FILES: usize = 200;+/// Largest dropped file read into memory, refused before the read begins (the paste cap).+const MAX_DROPPED_BYTES: f64 = (200u32 << 20) as f64;  /// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just /// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through