diff --git a/book/src/security/parser-hardening.md b/book/src/security/parser-hardening.md index 3b653eff42..7eedf0e039 100644 --- a/book/src/security/parser-hardening.md +++ b/book/src/security/parser-hardening.md @@ -14,7 +14,7 @@ Parser safety is broader than “does not crash.” A parser must also bound mem ### Native format -`photocraft-format` bounds manifest, blob, and total decompressed bytes; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests. +`photocraft-format` bounds manifest, blob, and total decompressed bytes; admits the declared canvas (by default at most 300000 pixels per side, the largest canvas `file.new`, Canvas Size and PSB import create, so whatever saves reopens; `LoadOptions::max_canvas_side` / `max_canvas_pixels` let a caller lower it) before decoding anything for it; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests. ## Review checklist diff --git a/crates/engine/src/document_preset_cmds.rs b/crates/engine/src/document_preset_cmds.rs index 01cd2cfc82..f0a8f6dbac 100644 --- a/crates/engine/src/document_preset_cmds.rs +++ b/crates/engine/src/document_preset_cmds.rs @@ -10,6 +10,9 @@ use crate::presets::{always, bad, req_str}; use crate::{Result, Session}; pub(crate) const MAX_DIMENSION: u32 = 300_000; +// What `file.new` creates must reopen: the `.pcraft` loader's default canvas budget covers it (#2935). +const _: () = + assert!(photocraft_format::MAX_CANVAS_SIDE >= MAX_DIMENSION && photocraft_format::MAX_CANVAS_PIXELS >= MAX_DIMENSION as u64 * MAX_DIMENSION as u64); pub(crate) const MAX_RESOLUTION: f64 = 30_000.0; const MAX_PRESETS: usize = 256; @@ -456,4 +459,16 @@ mod tests { assert!(s.documents().is_empty()); } } + + /// What `file.new` can create, `.pcraft` can reopen: the loader's default canvas budget + /// covers the largest new document (#2935), and such a document round-trips. + #[test] + fn the_largest_new_document_reopens_from_pcraft() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": MAX_DIMENSION, "height": MAX_DIMENSION, "background": "transparent"})).unwrap(); + let doc = &s.active().unwrap().doc; + let bytes = photocraft_format::save_to_bytes(doc, &Default::default()).unwrap(); + let back = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(back.size, photocraft_geom::Size::new(MAX_DIMENSION, MAX_DIMENSION)); + } } diff --git a/crates/format/fuzz/fuzz_targets/load.rs b/crates/format/fuzz/fuzz_targets/load.rs index 037d70cb5c..427713a997 100644 --- a/crates/format/fuzz/fuzz_targets/load.rs +++ b/crates/format/fuzz/fuzz_targets/load.rs @@ -5,7 +5,7 @@ use libfuzzer_sys::fuzz_target; use photocraft_format::{LoadOptions, load_from_bytes_with, read_manifest}; fuzz_target!(|data: &[u8]| { - let opts = LoadOptions { max_manifest_bytes: 1 << 20, max_blob_bytes: 16 << 20, max_total_bytes: 64 << 20, preserve_ids: false }; + let opts = LoadOptions { max_manifest_bytes: 1 << 20, max_blob_bytes: 16 << 20, max_total_bytes: 64 << 20, preserve_ids: false, ..Default::default() }; let _ = load_from_bytes_with(data, &opts); let _ = read_manifest(data); }); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 3156c83468..961ec37f1e 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -95,11 +95,35 @@ pub struct LoadOptions { /// Keep document and layer ids from the file (the id counter is advanced /// past them). When `false`, fresh ids are assigned. pub preserve_ids: bool, + /// Longest canvas side the manifest may declare (Photoshop's PSB limit, as for PSD and + /// `file.new`). + pub max_canvas_side: u32, + /// Most canvas pixels the manifest may declare. A canvas is a few bytes of JSON, but the + /// compositor, thumbnails and display textures work in proportion to its area, so a caller + /// handling untrusted files (a previewer, a batch job) may lower it. The default admits every + /// canvas the app lets a user create. + pub max_canvas_pixels: u64, } +/// Default [`LoadOptions::max_canvas_side`]. +pub const MAX_CANVAS_SIDE: u32 = 300_000; +/// Default [`LoadOptions::max_canvas_pixels`]: the full 300000 × 300000 PSB canvas. `file.new`, +/// Canvas Size, Image Size (Resample off) and PSB import all reach it, and a document that can be +/// created and saved must reopen. Loading itself stays proportional to the file: layers, masks +/// and channels are sparse tiles read from the bundle under the byte budgets, so a huge declared +/// canvas with no content allocates nothing for its area. +pub const MAX_CANVAS_PIXELS: u64 = MAX_CANVAS_SIDE as u64 * MAX_CANVAS_SIDE as u64; + impl Default for LoadOptions { fn default() -> Self { - LoadOptions { max_manifest_bytes: 256 << 20, max_blob_bytes: 1 << 30, max_total_bytes: 16 << 30, preserve_ids: true } + LoadOptions { + max_manifest_bytes: 256 << 20, + max_blob_bytes: 1 << 30, + max_total_bytes: 16 << 30, + preserve_ids: true, + max_canvas_side: MAX_CANVAS_SIDE, + max_canvas_pixels: MAX_CANVAS_PIXELS, + } } } diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 64ee7c0162..d7f6d45965 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -244,8 +244,24 @@ impl Fetch for LoadFetch<'_> { } } +/// Refuses a declared canvas outside `opts`' budget before anything is decoded for it (#2935). +fn admit_canvas(size: photocraft_geom::Size, opts: &LoadOptions) -> Result<()> { + let (w, h) = (size.width, size.height); + if w == 0 || h == 0 { + return Err(FormatError::corrupt(format!("canvas size {w}x{h} is empty"))); + } + if w > opts.max_canvas_side || h > opts.max_canvas_side { + return Err(FormatError::LimitExceeded(format!("canvas {w}x{h} exceeds {} pixels per side", opts.max_canvas_side))); + } + if size.area() > opts.max_canvas_pixels { + return Err(FormatError::LimitExceeded(format!("canvas {w}x{h} exceeds {} pixels", opts.max_canvas_pixels))); + } + Ok(()) +} + pub(crate) fn load(src: &dyn ByteSource, opts: &LoadOptions) -> Result { let m = read_manifest(src, opts)?; + admit_canvas(m.document.size, opts)?; let mut fetch = LoadFetch { src, opts: *opts, total: 0, blobs: HashMap::new() }; let mut loader = Loader { fetch: &mut fetch, diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index 18667ec198..9215e7cd10 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -387,3 +387,38 @@ fn tile_coordinates_outside_the_i32_pixel_range_are_rejected() { assert!(!s.content_bounds().is_empty()); } } + +/// A canvas is a few bytes of manifest JSON; one past the budget is refused before any work (#2935). +/// The default budget is the largest canvas the app creates (300000 × 300000, as `file.new`, +/// Canvas Size and PSB import allow), so everything a user can save reopens. +#[test] +fn declared_canvas_outside_the_budget_is_refused() { + let with_size = |w: u64, h: u64| { + rebuild(&sample(), |name, data| { + if name != "manifest.json" { + return Some(data); + } + let mut manifest: serde_json::Value = serde_json::from_slice(&data).unwrap(); + manifest["document"]["size"] = serde_json::json!({ "width": w, "height": h }); + Some(serde_json::to_vec(&manifest).unwrap()) + }) + }; + for (w, h) in [(300_001, 1), (1, 300_001), (300_001, 300_001), (u64::from(u32::MAX), u64::from(u32::MAX))] { + let r = load_from_bytes(&with_size(w, h)).map(|d| d.size); + assert!(matches!(r, Err(FormatError::LimitExceeded(_))), "{w}x{h}: {r:?}"); + } + assert!(load_from_bytes(&with_size(0, 64)).is_err()); + // The largest creatable canvas loads, without allocating anything for its area: the layers + // keep only the tiles the bundle holds. + let edge = with_size(300_000, 300_000); + let small = load_from_bytes(&sample()).unwrap(); + let big = load_from_bytes(&edge).unwrap(); + assert_eq!(big.size, photocraft_geom::Size::new(300_000, 300_000)); + let tiles = |d: &photocraft_doc::Document| d.layers.iter().filter_map(|l| l.surface()).map(|s| s.tile_count()).sum::(); + assert_eq!(tiles(&big), tiles(&small)); + // A caller may lower the budget. + let opts = LoadOptions { max_canvas_pixels: 1 << 20, ..Default::default() }; + assert!(matches!(load_from_bytes_with(&edge, &opts), Err(FormatError::LimitExceeded(_)))); + let opts = LoadOptions { max_canvas_side: 65_536, ..Default::default() }; + assert!(matches!(load_from_bytes_with(&with_size(65_537, 1), &opts), Err(FormatError::LimitExceeded(_)))); +}