Security: bounded flattening, plug-in budgets and caches, loaded textures, Revolve paint copies, color book names and ICC names

CodeRabbit findings #4, #34, #46, #51, #77, #101, #170, #171, #188:

- `vectorcraft_geom::flatten`: kurbo's flatten with each curve's tolerance raised so it makes
  at most ~4096 segments (non-finite curves become a line); the canvas outline and
  variable-width strokes use it.
- Plug-ins: a heavy filter's budget is the host's `Limits::heavy_fuel`/`heavy_wall_time_ms`,
  never more; a start function runs on one fuel slice; the effect cache holds at most ~1M
  anchors of inputs and results, and doesn't keep a result past a quarter of that.
- Loaded textures are cached by their PNG (base64 spacing doesn't matter), and live textures
  hold at most 512 MB together.
- Revolve draws the source path when the faces' paint copies would pass 256 MB.
- Color books: names with prefix and suffix take at most 64 MB.
- ICC: localized-name (`mluc`) records over 1 MB together are refused before moxcms copies them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dylan M. Taylor
2026-10-24 17:19:54 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent a608838ba2
commit e2a870e602
10 changed files with 257 additions and 28 deletions
+27 -7
View File
@@ -29,6 +29,10 @@ pub const WALL_TIME_MS: u64 = 5_000;
/// Cached results kept before the cache starts over.
const CACHE_SIZE: usize = 512;
/// Anchors the cached inputs and results may hold together before the cache starts over (about
/// 50 MB). A plug-in can return far more than it was given, so a count of results alone let a few
/// hundred small inputs keep gigabytes. A result past a quarter of this isn't kept.
const CACHE_ANCHORS: usize = 1 << 20;
/// The plug-in id of effect id `id` (`plugin.<id>`).
pub fn plugin_id(effect_id: &str) -> Option<&str> {
@@ -107,9 +111,18 @@ impl Key {
}
}
type Cache = HashMap<u64, (Key, Option<PathData>)>;
#[derive(Default)]
struct Cache {
entries: HashMap<u64, (Key, Option<PathData>)>,
/// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]).
anchors: usize,
}
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
fn anchors(p: &PathData) -> usize {
p.subpaths.iter().map(|sp| sp.anchors.len()).sum()
}
static ERRORS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
/// The last error of live-effect plug-in `id` (cleared when it next succeeds).
@@ -142,7 +155,7 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
path: path.clone(),
};
let h = key.hash64();
if let Some((k, out)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&h))
if let Some((k, out)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.entries.get(&h))
&& *k == key
{
return out.clone();
@@ -155,12 +168,19 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
}
note(id, &r);
let out = r.ok();
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = cache.get_or_insert_with(HashMap::new);
if cache.len() >= CACHE_SIZE {
cache.clear();
let size = anchors(&key.path) + out.as_ref().map_or(0, anchors);
if size > CACHE_ANCHORS / 4 {
return out;
}
cache.insert(h, (key, out.clone()));
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = cache.get_or_insert_with(Cache::default);
if cache.entries.len() >= CACHE_SIZE || cache.anchors + size > CACHE_ANCHORS {
*cache = Cache::default();
}
if let Some((old, old_out)) = cache.entries.insert(h, (key, out.clone())) {
cache.anchors = cache.anchors.saturating_sub(anchors(&old.path) + old_out.as_ref().map_or(0, anchors));
}
cache.anchors += size;
out
}
+14 -6
View File
@@ -46,6 +46,11 @@ pub struct Limits {
/// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no
/// monotonic clock in `std`, so the fuel budget alone bounds it there.
pub wall_time_ms: u64,
/// The most a heavy filter ([`crate::Budget::Heavy`]) is given instead of `fuel_base` and
/// `wall_time_ms`: what the host allows, which the module's manifest can ask for but never
/// raise. Set them to `fuel_base` and `wall_time_ms` to give heavy filters nothing more.
pub heavy_fuel: u64,
pub heavy_wall_time_ms: u64,
}
impl Default for Limits {
@@ -58,6 +63,8 @@ impl Default for Limits {
fuel_per_byte: 4_000,
max_recursion_depth: 1024,
wall_time_ms: 60_000,
heavy_fuel: HEAVY_FUEL,
heavy_wall_time_ms: HEAVY_WALL_TIME_MS,
}
}
}
@@ -170,11 +177,11 @@ impl Plugin {
e => e,
})?;
p.manifest = Manifest::parse(&text)?;
// A heavy filter's runs get the larger budget, which the host lets the user allow or
// refuse before installing it.
// A heavy filter's runs get the larger budget the host allows for one, which the host
// lets the user allow or refuse before installing it.
if p.manifest.budget == crate::Budget::Heavy {
p.limits.fuel_base = p.limits.fuel_base.max(HEAVY_FUEL);
p.limits.wall_time_ms = p.limits.wall_time_ms.max(HEAVY_WALL_TIME_MS);
p.limits.fuel_base = p.limits.fuel_base.max(p.limits.heavy_fuel);
p.limits.wall_time_ms = p.limits.wall_time_ms.max(p.limits.heavy_wall_time_ms);
}
// The other exports must exist with the right types.
live.func::<i32, i32>("vc_alloc")?;
@@ -217,8 +224,9 @@ impl Plugin {
StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
let mut store = Store::new(&self.engine, limits);
store.limiter(|l| l);
// The start function (if any) runs on this fuel.
store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?;
// The start function (if any) runs on one slice of fuel: it runs before `call` can check
// the deadline or a cancel between slices, so a heavy budget would let it run for minutes.
store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?;
let linker = Linker::<StoreLimits>::new(&self.engine);
let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?;
let memory =